diff --git a/.server-changes/dashboard-agent-watch-alerts.md b/.server-changes/dashboard-agent-watch-alerts.md new file mode 100644 index 00000000000..29473e5722c --- /dev/null +++ b/.server-changes/dashboard-agent-watch-alerts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Watches you set up with the dashboard agent can now alert you by email, Slack, or webhook when they fire. Pick the new "Dashboard agent watches" type on the Alerts page, and turn it off again from any alert email. diff --git a/.server-changes/dashboard-agent-watch-card.md b/.server-changes/dashboard-agent-watch-card.md new file mode 100644 index 00000000000..152e6bca719 --- /dev/null +++ b/.server-changes/dashboard-agent-watch-card.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +There's now a **Watch…** button on runs, queues, errors and the health report. It opens a short form with the right thing to wait for already filled in — a run finishing, a queue clearing, an error coming back, an environment recovering — so one click is enough. Open **Customize** first if you'd rather change how long it waits, how often it checks, or what it waits for, and you can ask for an email as well as the chat message. diff --git a/.server-changes/dashboard-agent-watches.md b/.server-changes/dashboard-agent-watches.md new file mode 100644 index 00000000000..d023193d8df --- /dev/null +++ b/.server-changes/dashboard-agent-watches.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Ask the dashboard agent to tell you when something happens — a run starting or finishing, a queue clearing or growing past a number you pick, an error coming back, an environment recovering — and it messages you in the chat once with the answer. It tells you either way: that the run finished, that it failed, or that the queue still hadn't cleared by the time it stopped looking. If the thing you asked about has already happened, it just says so instead of waiting. Each chat can wait on up to three things at a time, for up to 24 hours. You can also ask it to start looking into the cause if the news turns out to be bad, and it will — otherwise it just tells you and stops. diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx index 7f851c140eb..eda543b8fd8 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -1,10 +1,13 @@ -import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts"; -import { useCallback, useMemo, useState } from "react"; +import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { DashboardAgentPanel } from "./DashboardAgentPanel"; import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher"; @@ -15,6 +18,16 @@ import { readAgentFullscreen, writeAgentFullscreen, } from "./panel-layout"; +import { + showWatchWakesSummaryToast, + showWatchWakeToast, + WAKE_TOAST_MAX_INDIVIDUAL, + type WatchWake, +} from "./WatchWakeToast"; + +// How often the closed panel asks whether a watch woke a chat. A wake is worth +// noticing within a minute, and the count is one indexed query. +const UNREAD_POLL_INTERVAL_MS = 60_000; /** * Mounts the dashboard agent in the env layout. Renders the page content @@ -38,7 +51,18 @@ export function DashboardAgent({ // The product-controlled promoted prompt chip, from the feature flag. promotedPrompt?: SuggestedPrompt; }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`; + const [open, setOpen] = useState(false); + const [unreadWakes, setUnreadWakes] = useState(0); + // Wakes already toasted this session. Session-scoped on purpose: a wake that + // arrived overnight deserves the toast on the first poll after a reload, but a + // wake the user has already been shown (and maybe dismissed) must not come + // back every 60s while the chat stays unread. + const toastedWakes = useRef(new Set()); // The side panel is the default; someone who last worked fullscreen gets // fullscreen back. Read lazily so SSR always renders the side panel. const [fullscreen, setFullscreen] = useState(readAgentFullscreen); @@ -57,14 +81,39 @@ export function DashboardAgent({ const [requestedMessage, setRequestedMessage] = useState< { text: string; seq: number } | undefined >(undefined); + // A specific chat to open, from a wake toast. `seq` so the same chat can be + // asked for twice (a second wake in a chat the user has already left). + const [openChatRequest, setOpenChatRequest] = useState< + { chatId: string; seq: number } | undefined + >(undefined); + // A watch card asked for by a `Watch…` entry (§2.1). A card is not a message, + // so it travels on its own channel: the panel opens it pre-filled, and nothing + // reaches the transcript unless the user submits it. + const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; seq: number } | undefined>( + undefined + ); // Closing drops any pending request, so reopening the panel later doesn't // replay text the user has moved on from. const setPanelOpen = useCallback((next: boolean) => { setOpen(next); - // The panel unmounts on close, so a stale request would re-apply on the next - // open instead of restoring the last chat. - if (!next) setRequestedMessage(undefined); + // Closing drops both pending requests: the panel unmounts, so a stale one + // would re-apply on the next open instead of restoring the last chat. + if (!next) { + setRequestedMessage(undefined); + setOpenChatRequest(undefined); + // An abandoned card leaves no trace (§2.2) — including no pending request + // that would re-open it the next time the panel is. + setWatchRequest(undefined); + } + }, []); + + // Open the panel on the chat a wake happened in. Without the chat id the panel + // would just restore whatever it had open last, which is rarely the one the + // toast is about. + const openChat = useCallback((chatId: string) => { + setOpen(true); + setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 })); }, []); const openWith = useCallback((text: string) => { @@ -74,6 +123,72 @@ export function DashboardAgent({ setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 })); }, []); + const openWithWatch = useCallback((spec: WatchSpec) => { + setOpen(true); + setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 })); + }, []); + + // The dot's poll, and the toast's. Runs only while the panel is CLOSED — an + // open panel shows the wake in the transcript, so polling then would only race + // the read marker. Both the interval and the on-close refresh come from this + // effect re-running on `open`. + useEffect(() => { + if (!hasAccess || open) return; + + let cancelled = false; + const load = async () => { + try { + const res = await fetch(`${actionPath}?unread=1`); + if (!res.ok) return; + const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] }; + if (cancelled) return; + setUnreadWakes(data.unreadWakes ?? 0); + + const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId)); + for (const wake of fresh) toastedWakes.current.add(wake.watchId); + + // A burst gets one summary toast: a stack of persistent toasts is a wall, + // not a notification. + if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) { + showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true)); + } else { + // Oldest first, so the newest wake ends up nearest the user. + for (const wake of [...fresh].reverse()) { + showWatchWakeToast(wake, openChat); + } + } + } catch { + // Offline or a hiccup — leave the dot as it is and try again next tick. + } + }; + + void load(); + const interval = window.setInterval(load, UNREAD_POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [hasAccess, open, actionPath, setPanelOpen, openChat]); + + // A chat the user is now looking at has no unread wakes. Zeroes the dot right + // away (the poll restores the truth on close if another chat still has one) and + // persists the read marker for the chat that's actually visible. + const markChatRead = useCallback( + async (chatId: string) => { + setUnreadWakes(0); + const body = new FormData(); + body.set("intent", "read"); + body.set("chatId", chatId); + try { + await fetch(actionPath, { method: "POST", body }); + } catch { + // Not worth surfacing: the marker is caught up the next time the chat is + // opened. + } + }, + [actionPath] + ); + // ⌘J is contextual: closed → open the panel (the composer focuses itself, so // the keystroke lands you in the text field); open → start a new chat. // Closing is Esc or the header's ×, never ⌘J. @@ -96,8 +211,8 @@ export function DashboardAgent({ useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen }); const context = useMemo( - () => ({ open, setOpen: setPanelOpen, openWith }), - [open, setPanelOpen, openWith] + () => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes }), + [open, setPanelOpen, openWith, openWithWatch, unreadWakes] ); if (!hasAccess) { @@ -130,8 +245,11 @@ export function DashboardAgent({ setPanelOpen(false)} requestedMessage={requestedMessage} + openChatRequest={openChatRequest} + watchRequest={watchRequest} newChatSeq={newChatSeq} promotedPrompt={promotedPrompt} + onChatRead={markChatRead} isFullscreen={fullscreen} onToggleFullscreen={toggleFullscreen} /> diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index e24ad13f306..a14c1d9ee78 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -1,7 +1,7 @@ import { useChat } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react"; import type { dashboardAgent } from "@internal/dashboard-agent"; -import type { AgentIntent, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import type { AgentIntent, SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts"; import { useNavigate } from "@remix-run/react"; import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -16,6 +16,7 @@ import { appendRunFilters, pendingNavigateIntents } from "./navigate-target"; import type { AgentPageContext } from "./page-context-types"; import { useAgentMessageQuota } from "./useAgentMessageQuota"; import { useTriggerUriResolver } from "./useTriggerUriResolver"; +import { WatchChips, type WatchChip } from "./WatchChips"; // The persisted session for a chat: the session-scoped token plus the stream // cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream @@ -59,7 +60,12 @@ export function DashboardAgentChat({ streaming, prefill, promotedPrompt, + watches, pagePaths, + watchCard, + appendedMessage, + onWatchIntent, + onCancelWatch, onTurnSettled, onActivityChange, }: { @@ -87,9 +93,27 @@ export function DashboardAgentChat({ // The product-controlled promoted chip, from the feature flag. Only used for // the suggested prompts on an empty chat. promotedPrompt?: SuggestedPrompt; + // This chat's active watches, from the panel's history load. + watches: WatchChip[]; /** Host-resolved dashboard paths for settings-page footer actions. */ pagePaths?: Record; - /** A turn settled — tell the panel to refresh its history list. */ + /** The ephemeral watch card, when one is open. Sits above the composer. */ + watchCard?: React.ReactNode; + /** + * A message the SERVER appended outside a turn — the watch card's confirmation + * or one-shot result. It is already durable in the store; this puts it in the + * live transcript now instead of on the next open. `seq` makes each append + * distinct, so the effect applies it exactly once. + */ + appendedMessage?: { message: UIMessage; seq: number }; + /** + * A card offered a watch. Every `watch` intent means the same thing — open the + * configuration card pre-filled with this spec — so the user reviews and + * submits it, and nothing is posted or persisted if they don't (§2.2). + */ + onWatchIntent?: (spec: WatchSpec) => void; + onCancelWatch: (watchId: string) => void; + /** A watch was created — tell the panel to re-read the chips. */ onTurnSettled: () => void; /** * Whether a turn is in flight, for the History list's row marker. Only this @@ -165,6 +189,7 @@ export function DashboardAgentChat({ const { messages: rawMessages, + setMessages, sendMessage, status, stop: aiStop, @@ -199,6 +224,20 @@ export function DashboardAgentChat({ const activity: TurnActivity | null = status === "submitted" ? "thinking" : status === "streaming" ? "working" : null; + // A server-appended block (the watch card's outcome) joins the live transcript + // in place. Applied once per `seq`: the append is already persisted, so + // replaying it would show the same confirmation twice. + const appendedSeq = useRef(undefined); + useEffect(() => { + if (!appendedMessage || appendedSeq.current === appendedMessage.seq) return; + appendedSeq.current = appendedMessage.seq; + setMessages((current) => + current.some((message) => message.id === appendedMessage.message.id) + ? current + : [...current, appendedMessage.message] + ); + }, [appendedMessage, setMessages]); + // Cold start: trigger the first turn by sending the pending message once. const sentFirst = useRef(false); useEffect(() => { @@ -263,8 +302,14 @@ export function DashboardAgentChat({ ); // What a card's action does. An `ask` goes back into the conversation as the - // user's own question, so the click is visible in the transcript rather than - // happening silently. + // user's own question. + // + // A `watch` does NOT: it opens the configuration card pre-filled with the spec + // the card offered. Every watch intent is treated this way, whatever offered it + // — so the user always sees what they are about to start, can change the window + // or the condition first, and an offer they walk away from leaves no trace. It + // used to post a visible "Watch this for me…" request and let the agent answer + // with schedule_watch; the card replaces that turn with 0 LLM. // // `propose_fix` is reserved and must never be executed. const handleIntent = useCallback( @@ -273,6 +318,9 @@ export function DashboardAgentChat({ case "ask": submit(intent.prompt); return; + case "watch": + onWatchIntent?.(intent.spec); + return; case "navigate": void goTo(intent); return; @@ -280,7 +328,7 @@ export function DashboardAgentChat({ console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`); } }, - [submit, goTo] + [submit, goTo, onWatchIntent] ); // The `navigate_to` tool answers with an intent and the agent then narrates it @@ -324,6 +372,15 @@ export function DashboardAgentChat({ return ( <> + {/* What this chat is watching, at the top of the panel: a watch outcome + arrives in the transcript unprompted, so the chips are what explain + where those messages will come from. */} + {/* Chips are an offer to cancel, so only live watches get one; the full + list still flows to the messages for the wake banner's tone. */} + watch.status === "active")} + onCancel={onCancelWatch} + /> {/* A cold-start chat mounts with no messages and a first message about to be sent, so the prompts would flash for a frame before the transcript replaced them. Gate on that pending send. */} @@ -344,9 +401,11 @@ export function DashboardAgentChat({ onDismissError={clearError} onIntent={handleIntent} pagePaths={pagePaths} + watches={watches} resolveUri={resolveUri} /> )} + {watchCard} {/* The Free plan's message cap occupies the composer slot: at the cap the composer is replaced by the upgrade block (a composer you can't send from is worse than none), and under it the composer is followed by the diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx index 4d0bf424aa3..1aa33368d86 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx @@ -24,6 +24,7 @@ export function DashboardAgentDraft({ currentPage, pageContext, promotedPrompt, + watchCard, }: { onSubmit: (text: string) => void; projectSlug: string; @@ -33,6 +34,8 @@ export function DashboardAgentDraft({ pageContext?: AgentPageContext; // The product-controlled promoted chip, from the feature flag. promotedPrompt?: SuggestedPrompt; + /** The ephemeral watch card, when one is open. Sits above the composer. */ + watchCard?: React.ReactNode; }) { const [input, setInput] = useState(""); @@ -68,22 +71,28 @@ export function DashboardAgentDraft({ pageContext={pageContext} promoted={promotedPrompt} composer={ - submit(input)} - onStop={() => {}} - isStreaming={false} - placeholderSuggestion={placeholderSuggestion} - context={ - - } - /> + // The card rides in the composer slot, directly above the field — the + // same place a chat puts it, so an ephemeral card reads the same in the + // blank state as it does mid-conversation. +
+ {watchCard} + submit(input)} + onStop={() => {}} + isStreaming={false} + placeholderSuggestion={placeholderSuggestion} + context={ + + } + /> +
} /> ); diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx index 6cf99e06f59..48b4e7c7825 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx @@ -7,31 +7,40 @@ import { FormButtons } from "~/components/primitives/FormButtons"; import { Paragraph } from "~/components/primitives/Paragraph"; import { AgentSpinner } from "~/components/primitives/Spinner"; import { AgentList, AgentListRow, AgentListRowAction } from "./list-row"; +import type { WatchChip } from "./WatchChips"; // Date fields arrive as strings over the loader's JSON. export type DashboardAgentChat = { id: string; title: string; lastMessageAt: string | null; + /** The chat's active watches, for the panel's chip row. */ + watches?: WatchChip[]; + /** A watch resolved in this chat and the user hasn't opened it since. */ + hasUnreadWake?: boolean; + /** The chat holds at least one active watch. */ + hasActiveWatch?: boolean; /** The chat's latest investigation is still `in_progress`. */ hasOpenInvestigation?: boolean; }; /** Something is running in this chat. One per row, most immediate first. */ -type ChatProcess = "thinking" | "investigating"; +type ChatProcess = "thinking" | "investigating" | "watching"; const PROCESS_LABELS: Record = { thinking: "Agent is thinking", investigating: "Investigation in progress", + watching: "Watch active", }; /** * `thinking` outranks the rest: a turn in flight is the thing that's about to - * change, an investigation just sits there. + * change, an investigation or a watch just sits there. */ function chatProcess(chat: DashboardAgentChat, isThinking: boolean): ChatProcess | null { if (isThinking) return "thinking"; if (chat.hasOpenInvestigation) return "investigating"; + if (chat.hasActiveWatch) return "watching"; return null; } @@ -44,12 +53,25 @@ function ProcessIcon({ process }: { process: ChatProcess }) { {process === "investigating" ? ( ) : ( + // Thinking and watching both spin — "something is going on here"; the + // hover title says which. )} ); } +/** + * Chats with an unread wake go to the top — a watch that fired is the reason to + * open the panel at all. Everything else keeps the server's order (pinned first, + * then most recent), so this is a stable sort on one key. + */ +function unreadFirst(chats: DashboardAgentChat[]): DashboardAgentChat[] { + return [...chats].sort( + (a, b) => Number(b.hasUnreadWake ?? false) - Number(a.hasUnreadWake ?? false) + ); +} + /** Units the row's age can be shown in. Months and years would read as "1.8mo" for * eight weeks, which is worse than "8w" — weeks are the coarsest useful unit. */ const AGE_UNITS = ["w", "d", "h", "m"] as const; @@ -70,8 +92,8 @@ export function chatAge(lastMessageAt: string, now: number = Date.now()): string /** * The chat list, as the body of the header's title dropdown. Rows keep the - * panel's list language (process icon, hover delete) — only the container - * changed from a full panel view to a popover menu. + * panel's list language (unread dot, process icon, hover delete) — only the + * container changed from a full panel view to a popover menu. */ export function DashboardAgentHistoryMenu({ chats, @@ -104,13 +126,14 @@ export function DashboardAgentHistoryMenu({ ) : ( - {chats.map((chat) => { + {unreadFirst(chats).map((chat) => { const process = chatProcess(chat, chat.id === thinkingChatId); const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined; return ( : null} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx index af744638c4b..7cd6e7270ae 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx @@ -17,11 +17,13 @@ import { ChatText, ChatTranscript, ChatTurn, + ChatWakeSlot, } from "./chat-layout"; import { toolPendingLabel } from "./tool-labels"; import { reportBlockFromToolPart } from "./report-block-adapter"; import type { ResolvedUri } from "./ReportView"; import { ViewBlocks } from "./view-catalog"; +import { findWakeWatch, WakeBanner, wakeRefFromMessageId, type WakeWatch } from "./WakeBanner"; // "thinking" — the turn is submitted but nothing has come back yet. // "working" — the turn is streaming: text, or (more often) tool calls, which can @@ -48,6 +50,13 @@ export type DashboardAgentMessagesProps = { resolveUri?: (uri: string) => ResolvedUri | null; /** Host-resolved dashboard paths for settings-page footer actions. */ pagePaths?: Record; + /** + * The chat's watches, when the host has them. A wake message names the watch + * it came from, so this is what lets its banner say *what* was being watched + * and colour the outcome by kind. Without it a wake still gets a banner, in + * kind-agnostic wording. + */ + watches?: WakeWatch[]; }; // The shared MessageBubble renders `step-start` parts as a dashed "step" @@ -85,10 +94,27 @@ function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } | function blocksFor(part: UIMessage["parts"][number]): unknown[] | null { const spec = viewSpecFor(part); if (spec) return spec.blocks; + const hostBlocks = hostViewBlocks(part); + if (hostBlocks) return hostBlocks; const report = reportBlockFromToolPart(part); return report ? [report] : null; } +/** + * Blocks the HOST wrote, with no tool behind them. + * + * The watch card's confirmation and its one-shot result are deterministic facts + * the webapp decided (§2.2) — there is no model turn and no tool call to hang + * them off, so they travel as a plain `data-view` part and render through exactly + * the same `ViewBlocks` catalog as everything else. Same envelope, same + * latest-wins, one renderer. + */ +function hostViewBlocks(part: UIMessage["parts"][number]): unknown[] | null { + const p = part as { type: string; data?: { blocks?: unknown[] } }; + if (p.type !== "data-view") return null; + return Array.isArray(p.data?.blocks) ? p.data!.blocks! : null; +} + type InvestigationRef = { id: string; revision: number }; function investigationRef(block: unknown): InvestigationRef | null { @@ -243,18 +269,21 @@ function userText(message: UIMessage): string { // Renders one message as one turn. A user turn is the accent bubble; assistant // parts go through the panel's own renderer, and a card-producing part -// (render_view / get_report) becomes a catalog card instead of a tool row. +// (render_view / get_report) becomes a catalog card instead of a tool row. A +// wake — an assistant turn nobody asked for — keeps the same body under a banner. const DashboardAgentTurn = memo(function DashboardAgentTurn({ message, onIntent, resolveUri, pagePaths, + watches, investigationWinners, }: { message: UIMessage; onIntent?: (intent: AgentIntent) => void; resolveUri?: (uri: string) => ResolvedUri | null; pagePaths?: Record; + watches?: WakeWatch[]; /** See {@link winningInvestigationOccurrences}. */ investigationWinners?: Map; }) { @@ -332,6 +361,24 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({ body.push(renderDashboardPart(part, i, { suppressPendingPill: hasLiveInvestigationCard })); } + // A wake narration is identified by the message id the agent wrote it under, + // so nothing about the parts has to change: same prose, with a banner above it + // saying the watch — not the user — started this turn. + const wake = wakeRefFromMessageId(message.id); + if (wake) { + return ( + + + } + > + {body} + + + ); + } + return {body}; }); @@ -349,6 +396,7 @@ export function DashboardAgentTurns({ onIntent, resolveUri, pagePaths, + watches, }: DashboardAgentMessagesProps) { // One status line at a time: a tool's own progress beats the generic activity. const showActivity = activity !== null && !hasToolProgressLine(messages); @@ -370,6 +418,7 @@ export function DashboardAgentTurns({ onIntent={onIntent} resolveUri={resolveUri} pagePaths={pagePaths} + watches={watches} investigationWinners={investigationWinners} /> ))} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index b41ccdf9c7d..2b57908b996 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -16,10 +16,12 @@ import { type DashboardAgentSession, } from "./DashboardAgentChat"; import { DashboardAgentDraft } from "./DashboardAgentDraft"; +import { WatchCard } from "./WatchCard"; +import { watchDraftFor } from "./watch-card"; import type { TurnActivity } from "./DashboardAgentMessages"; import { DashboardAgentHeader } from "./DashboardAgentHeader"; import type { DashboardAgentChat as DashboardAgentChatListItem } from "./DashboardAgentHistory"; -import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import type { SuggestedPrompt, WatchDraft, WatchSpec } from "@internal/dashboard-agent-contracts"; import type { AgentPageContext } from "./page-context-types"; import { agentPageLabel } from "./page-label"; import { AgentPanelColumn } from "./panel-layout"; @@ -81,8 +83,11 @@ type ActiveChat = { export function DashboardAgentPanel({ onClose, requestedMessage, + openChatRequest, newChatSeq, promotedPrompt, + watchRequest, + onChatRead, isFullscreen = false, onToggleFullscreen, }: { @@ -94,11 +99,19 @@ export function DashboardAgentPanel({ // Text handed to the panel from outside (`openWith`). `seq` distinguishes // repeat requests with the same text. requestedMessage?: { text: string; seq: number }; + // A specific chat to show, from outside the panel (a wake toast). `seq` + // distinguishes repeat requests for the same chat. + openChatRequest?: { chatId: string; seq: number }; // Bumped by the contextual ⌘J while the panel is open — each change starts a // new chat. newChatSeq?: number; // The product-controlled promoted prompt chip, from the feature flag. promotedPrompt?: SuggestedPrompt; + // A watch card asked for by a `Watch…` entry. `seq` distinguishes repeats. + watchRequest?: { spec: WatchSpec; seq: number }; + // The chat in front of the user changed, so its watch wakes are seen — clears + // the launcher's unread dot. + onChatRead?: (chatId: string) => void; }) { const organization = useOrganization(); const project = useProject(); @@ -160,11 +173,16 @@ export function DashboardAgentPanel({ ); }, []); - // The list is reloaded from several places at once (on open, and on every - // settled turn), so a single in-flight request is shared instead of stacking: + // The list is reloaded from several places at once (open, every settled turn, a + // watch change), so a single in-flight request is shared instead of stacking: // callers that arrive while one is running await that one and see its result. const historyInFlight = useRef | null>(null); + // Chats read since the last reload. The read POST and the reload it triggers + // can land out of order, so the next list is masked with what we know was + // read; the ids are then dropped, so a later wake in the same chat still shows. + const justRead = useRef>(new Set()); + const loadHistory = useCallback(async () => { if (historyInFlight.current) return historyInFlight.current; const request = (async () => { @@ -172,7 +190,13 @@ export function DashboardAgentPanel({ const res = await fetch(actionPath); if (!res.ok) throw new Error(`History request failed (${res.status})`); const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] }; - setChats(data.chats ?? []); + const read = justRead.current; + justRead.current = new Set(); + setChats( + (data.chats ?? []).map((chat) => + read.has(chat.id) ? { ...chat, hasUnreadWake: false } : chat + ) + ); } catch (error) { console.error("Dashboard agent: failed to load chat history", error); toast.error("We couldn't load your previous chats. Try again in a moment."); @@ -308,6 +332,21 @@ export function DashboardAgentPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [openChat, storageKey, loadHistory]); + // A chat asked for from outside: a wake toast is about one conversation, so it + // opens that one. Runs after the mount-time restore, and `openChat` invalidates + // any request already in flight, so the asked-for chat is the one that lands. + const handledOpenChatSeq = useRef(undefined); + useEffect(() => { + if (!openChatRequest || handledOpenChatSeq.current === openChatRequest.seq) return; + handledOpenChatSeq.current = openChatRequest.seq; + // Already the visible transcript — nothing to load, and reloading it would + // drop a turn in flight. + if (openChatRequest.chatId === active?.chatId) return; + void openChat(openChatRequest.chatId); + // `active` is read, not tracked: a later change must not re-run the request. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openChatRequest, openChat]); + // Persist the active chat and the page it's being used on — navigating with // the panel open keeps the chat, so the stored path follows along. useEffect(() => { @@ -322,6 +361,27 @@ export function DashboardAgentPanel({ } }, [active?.chatId, storageKey, location.pathname]); + // A chat becoming the visible transcript is the user reading it — mark it read + // so the launcher's dot clears. Fires on open and on every chat switch; a draft + // has nothing to read. + useEffect(() => { + if (!active?.chatId) return; + const chatId = active.chatId; + onChatRead?.(chatId); + // Clear the row's highlight now rather than waiting for the next history + // reload, so reopening the dropdown after reading doesn't show it as unread. + justRead.current.add(chatId); + setChats((previous) => + previous.map((chat) => (chat.id === chatId ? { ...chat, hasUnreadWake: false } : chat)) + ); + // Read it again on the way out, so a wake that landed while the chat was in + // front of the user doesn't come back as unread when the panel closes. + return () => { + onChatRead?.(chatId); + justRead.current.add(chatId); + }; + }, [active?.chatId, onChatRead]); + // Text handed in by `openWith`. With no chat open we start one and send it // straight away (the launcher's caller already knows what to ask); with a chat // open we only drop it into the composer, so we never inject a message into @@ -345,6 +405,104 @@ export function DashboardAgentPanel({ } }, [requestedMessage, loading, active, createChat]); + // --------------------------------------------------------------------- + // The watch card (§2.2). Everything here is EPHEMERAL until `submitWatch` + // succeeds: the draft, the pending flag and the error all live in the panel, + // and dropping the card drops all three without touching the transcript. + // --------------------------------------------------------------------- + const [watchDraft, setWatchDraft] = useState(null); + const [watchPending, setWatchPending] = useState(false); + const [watchError, setWatchError] = useState(null); + // The block the server appended, handed to the open chat so it appears now + // rather than on the next open. `seq` makes each append distinct. + const [appendedMessage, setAppendedMessage] = useState< + { message: UIMessage; seq: number } | undefined + >(undefined); + + const handledWatchSeq = useRef(undefined); + useEffect(() => { + if (!watchRequest || handledWatchSeq.current === watchRequest.seq) return; + handledWatchSeq.current = watchRequest.seq; + setWatchError(null); + setWatchPending(false); + setWatchDraft(watchDraftFor(watchRequest.spec)); + }, [watchRequest]); + + // A card offering a watch (an investigation's recurrence action, the health + // report's recovery offer) opens the SAME card, pre-filled — never a posted + // request, so an offer the user walks away from leaves no trace. + const openWatchCard = useCallback((spec: WatchSpec) => { + setWatchError(null); + setWatchPending(false); + setWatchDraft(watchDraftFor(spec)); + }, []); + + const dismissWatchCard = useCallback(() => { + setWatchDraft(null); + setWatchError(null); + setWatchPending(false); + }, []); + + const submitWatch = useCallback(async () => { + if (!watchDraft) return; + setWatchPending(true); + setWatchError(null); + try { + const body = new FormData(); + body.set("intent", "watch-create"); + body.set("draft", JSON.stringify(watchDraft)); + // No chat open: the server creates one and returns its id. A watch is + // chat-bound, so there is nowhere else for it to live. + if (active?.chatId) body.set("chatId", active.chatId); + + const res = await fetch(actionPath, { method: "POST", body }); + const data = (await res.json()) as { + chatId?: string; + message?: UIMessage; + error?: string; + }; + if (!res.ok || !data.chatId || !data.message) { + // Validation, cap and network failures stay in the card and persist + // nothing — the user fixes the draft in place. + setWatchError(data.error ?? "We couldn't start that watch. Try again in a moment."); + return; + } + + if (active?.chatId === data.chatId) { + setAppendedMessage((current) => ({ + message: data.message!, + seq: (current?.seq ?? 0) + 1, + })); + } else { + // A chat that did not exist a moment ago: mount it on what the server + // wrote. No session — nothing is streaming, the block is the whole chat. + setActive({ chatId: data.chatId, messages: [data.message], session: null }); + } + // The card BECOMES the persisted block, so it goes the moment that block + // is in the transcript (§2.2 step 3/4). + setWatchDraft(null); + void loadHistory(); + } catch (error) { + console.error("Dashboard agent: failed to create watch", error); + setWatchError("We couldn't start that watch. Try again in a moment."); + } finally { + setWatchPending(false); + } + }, [watchDraft, active?.chatId, actionPath, loadHistory]); + + const watchCard = watchDraft ? ( +
+ void submitWatch()} + onCancel={dismissWatchCard} + pending={watchPending} + error={watchError} + /> +
+ ) : null; + const newChat = useCallback(() => { // Invalidate any in-flight open/create so its result can't replace the draft. openChatRequestSeq.current += 1; @@ -388,12 +546,49 @@ export function DashboardAgentPanel({ [actionPath, active?.chatId, newChat, loadHistory, toast] ); + // Stop watching, from the chip's ×. The chip goes immediately (the cancel is a + // single guarded UPDATE and hardly ever fails), and the reload right after + // restores the truth either way. + const cancelWatch = useCallback( + async (watchId: string) => { + const chatId = active?.chatId; + if (!chatId) return; + setChats((previous) => + previous.map((chat) => + chat.id === chatId + ? { ...chat, watches: (chat.watches ?? []).filter((watch) => watch.id !== watchId) } + : chat + ) + ); + const body = new FormData(); + body.set("intent", "watch-cancel"); + body.set("chatId", chatId); + body.set("watchId", watchId); + try { + const res = await fetch(actionPath, { method: "POST", body }); + if (!res.ok) throw new Error(`Watch cancel failed (${res.status})`); + } catch (error) { + console.error("Dashboard agent: failed to cancel watch", error); + toast.error("We couldn't stop that watch. Try again in a moment."); + } + void loadHistory(); + }, + [actionPath, active?.chatId, loadHistory, toast] + ); + // The header names what you're looking at. Titles come from the history list // (the agent writes one when the first turn settles), so a brand-new chat has // none yet and falls back to "Chat". const activeChat = active ? chats.find((chat) => chat.id === active.chatId) : undefined; const headerTitle = active ? (activeChat?.title ?? "Chat") : "New chat"; + // The watches ride along on the history list (one query for every chat), so + // they refresh whenever it does: on open, when a turn settles, and after a + // watch is created or cancelled. The FULL list goes down — the chips filter + // to active themselves (a chip is an offer to cancel), while the wake banner + // needs the kind of a watch that has already fired. + const chatWatches = activeChat?.watches ?? []; + return (
@@ -457,6 +657,7 @@ export function DashboardAgentPanel({ currentPage={currentPage} pageContext={pageContext} promotedPrompt={promotedPrompt} + watchCard={watchCard} /> )} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx index 01902022874..3bf5fa95968 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx @@ -1,6 +1,6 @@ import { BookOpenIcon, - ChartBarIcon, + EyeIcon, MagnifyingGlassIcon, QuestionMarkCircleIcon, SparklesIcon, @@ -21,7 +21,7 @@ import { * * - `promoted` / `investigate` — something to do about a problem, so they get * the indigo primary the rest of the app uses for the main action. - * - `status` — "what's going on right now", a neutral query: secondary. + * - `watch` — "tell me when this changes", a standing offer: secondary. * - `explain` — the evergreen question, the quietest of the set: tertiary. * - `docs` — a documentation question, so it gets the docs variant, the same * style every "read the docs" button in the dashboard has. @@ -32,7 +32,7 @@ export const PROMPT_SLOT_BUTTON: Record< > = { promoted: { variant: "primary/medium", icon: SparklesIcon }, investigate: { variant: "primary/medium", icon: MagnifyingGlassIcon }, - status: { variant: "secondary/medium", icon: ChartBarIcon }, + watch: { variant: "secondary/medium", icon: EyeIcon }, explain: { variant: "tertiary/medium", icon: QuestionMarkCircleIcon }, docs: { variant: "docs/medium", icon: BookOpenIcon }, }; diff --git a/apps/webapp/app/components/dashboard-agent/ReportView.tsx b/apps/webapp/app/components/dashboard-agent/ReportView.tsx index 87fc9bed949..8912dd44119 100644 --- a/apps/webapp/app/components/dashboard-agent/ReportView.tsx +++ b/apps/webapp/app/components/dashboard-agent/ReportView.tsx @@ -40,6 +40,7 @@ import { healthMessages } from "~/presenters/v3/reports/health/health-messages"; import { type ReportMessages } from "~/presenters/v3/reports/report-messages"; import { reportIsTrustworthy } from "./report-block-adapter"; import { + FOOTER_WATCH_CODE, ReportBody, ReportCard, ReportFindingLine, @@ -63,6 +64,9 @@ import { export type ResolvedUri = { label: string; url: string }; +/** How often a recovery watch polls, and how long it lives. Aggregate conditions floor at 5m. */ +const RECOVERY_WATCH = { checkEveryMinutes: 5, maxHours: 6 } as const; + // --- messages --------------------------------------------------------------- /** @@ -485,6 +489,22 @@ export function ReportView({ .filter((finding) => finding.read !== undefined) .map((finding) => fillTokens(messages.readMessage(finding.read!), tokens)); + // "Tell me when this recovers" — only offered when there is something to + // recover from, and only for the health report, whose watch kind exists. + const recoveryWatch: AgentIntent | null = + vm.title === "health" && (severity === "warn" || severity === "crit") + ? { + kind: "watch", + spec: { + kind: "health_recovery", + report: "health", + fromSeverity: severity, + note: `${vm.scope} health back to normal`, + ...RECOVERY_WATCH, + }, + } + : null; + // Links a footer action already speaks for aren't repeated as reading matter. const footerLinkKeys = new Set(vm.footer.map((entry) => entry.link).filter(Boolean)); @@ -502,6 +522,24 @@ export function ReportView({ }), })); + if (recoveryWatch && onIntent) { + const watchItem: ReportFooterItem = { + code: FOOTER_WATCH_CODE, + // The label is the universal one (§2.1, binding): the ENTRY is the same + // everywhere and only the pre-filled recommendation is contextual, so a + // per-object CTA ("Watch recovery") would break the pattern. + node: onIntent(recoveryWatch)}>Watch…, + }; + // The watch joins the other buttons in the row, BEFORE the trailing + // "or do nothing" prose. + const noteIndex = footerItems.findIndex((item) => reportFooterStyle(item.code) === "note"); + if (noteIndex !== -1) { + footerItems.splice(noteIndex, 0, watchItem); + } else { + footerItems.push(watchItem); + } + } + // Resources the report cites, resolved to dashboard links by the host. Cited, // not offered — so a text link (our docs still get the docs button). for (const link of vm.links) { diff --git a/apps/webapp/app/components/dashboard-agent/WakeBanner.tsx b/apps/webapp/app/components/dashboard-agent/WakeBanner.tsx new file mode 100644 index 00000000000..882bdf5b4bb --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/WakeBanner.tsx @@ -0,0 +1,174 @@ +/** + * The banner above a wake narration. + * + * A wake arrives unprompted: nobody typed anything, the watch resolved and the + * chat spoke. Rendered as plain assistant prose it would read like an answer to a + * question the user never asked — so the narration gets a banner that states the + * FACT before the text does, with the outcome carried by a coloured accent and + * icon (the same rule the run status cells and the watch chips follow: the text + * keeps its colour, the state is the icon's job). + * + * **This component contains no kind-specific wording.** Category, tone, semantic + * icon and headline key come from the exhaustive resolved-result mapping in + * contracts; the final English comes from `watch-presentation.ts`. All this file + * decides is which glyph a semantic icon draws and which frame a tone paints. + * + * A wake is identified by its message id — `wake:watch:{watchId}:{fired|expired}`. + * That two-value suffix is the stable TRANSPORT encoding (§7.5): it is not the + * outcome, it is how the wake is addressed. The outcome comes off the watch row. + */ +import { + CheckCircleIcon, + ClockIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + InformationCircleIcon, +} from "@heroicons/react/20/solid"; +import type { + WatchObservedOutcome, + WatchResolution, + WatchSemanticIcon, +} from "@internal/dashboard-agent-contracts"; +import { cn } from "~/utils/cn"; +import { type AgentTone, TONE_ICON_COLOR } from "./agent-badges"; +import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "./watch-presentation"; + +const WAKE_ID_PREFIX = "wake:watch:"; + +/** + * The wire encoding in a wake's message id. NOT the resolution — a + * `window_completed` and a `condition_impossible` are both addressed as + * `expired`, and the row is the authority on which one it was. + */ +export type WakeOutcome = "fired" | "expired"; + +/** The watch fields a banner can use. A `WatchChip` satisfies it. */ +export type WakeWatch = { + id: string; + kind: string; + note: string; + identity: string; + /** How the watch ended. Absent on a row written before the resolution model. */ + resolution?: WatchResolution | null; + /** What the resolving check observed — the other half of the headline. */ + observedOutcome?: WatchObservedOutcome | null; + /** + * Why the watch ended, from its last result — `terminal_unsatisfied` when the + * condition became impossible. Only used to reconstruct a resolution for rows + * that predate the `resolution` column. + */ + endedReason?: string | null; +}; + +export type WakeRef = { watchId: string; outcome: WakeOutcome }; + +/** + * The watch a message narrates the wake of, or null when the message isn't a + * wake. The id is `wake:watch:{watchId}:{outcome}`; a watch id never ends in an + * outcome word, so splitting on the last colon is unambiguous. + */ +export function wakeRefFromMessageId(messageId: string): WakeRef | null { + if (!messageId.startsWith(WAKE_ID_PREFIX)) return null; + const rest = messageId.slice(WAKE_ID_PREFIX.length); + const split = rest.lastIndexOf(":"); + if (split <= 0) return null; + const outcome = rest.slice(split + 1); + if (outcome !== "fired" && outcome !== "expired") return null; + return { watchId: rest.slice(0, split), outcome }; +} + +/** The watch a wake belongs to, when the host passed its watches down. */ +export function findWakeWatch(watches: WakeWatch[] | undefined, watchId: string) { + return watches?.find((watch) => watch.id === watchId); +} + +/** + * The watch's resolution, falling back to what the transport can prove for a row + * written before the `resolution` column existed. `fired` is unambiguous; + * `expired` splits on the last check's reason, exactly as the old banner did. + */ +export function wakeResolution( + outcome: WakeOutcome, + watch: Pick | undefined +): WatchResolution { + if (watch?.resolution) return watch.resolution; + if (outcome === "fired") return "condition_met"; + return watch?.endedReason === "terminal_unsatisfied" + ? "condition_impossible" + : "window_completed"; +} + +/** + * What this banner shows. Exported for the tests and for any surface that wants + * the same answer without the markup. + */ +export function wakePresentation(outcome: WakeOutcome, watch: WakeWatch | undefined) { + if (!watch) return WATCH_PRESENTATION_FALLBACK; + return presentResolvedWatch({ + kind: watch.kind, + identity: watch.identity, + resolution: wakeResolution(outcome, watch), + observed: watch.observedOutcome ?? null, + }); +} + +/** + * Semantic icon → glyph. The mapping lives here because the icon SET is this + * app's; which icon a resolved result deserves was decided in contracts, and the + * rule it encodes is that the icon follows the outcome, never the resolution — a + * failed run gets `error`, not the success check its `condition_met` would + * otherwise suggest. + */ +const SEMANTIC_ICON: Record JSX.Element> = { + success: CheckCircleIcon, + attention: ExclamationTriangleIcon, + error: ExclamationCircleIcon, + waiting: ClockIcon, + info: InformationCircleIcon, +}; + +const TONE_FRAME: Record = { + neutral: "border-l-border-bright bg-background-bright/40", + success: "border-l-success bg-success/10", + warning: "border-l-warning bg-warning/10", + error: "border-l-error bg-error/10", +}; + +/** What the watch was for: the user's own words, else whatever names it. */ +function subline(watch: WakeWatch | undefined): string | null { + const note = watch?.note.trim(); + if (note) return note; + if (watch?.identity) return watch.identity; + if (watch?.kind) return watch.kind; + return null; +} + +export function WakeBanner({ + outcome, + watch, +}: { + /** The wire encoding from the wake's message id (§7.5). */ + outcome: WakeOutcome; + /** The watch that woke, when the host has it. Absent: the neutral fallback. */ + watch?: WakeWatch; +}) { + const presentation = wakePresentation(outcome, watch); + const tone = presentation.tone as AgentTone; + const Icon = SEMANTIC_ICON[presentation.semanticIcon]; + const note = subline(watch); + + return ( +
+ +
+

+ {presentation.label} +

+

{presentation.headline}

+ {note ?

{note}

: null} +
+
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/WatchButton.tsx b/apps/webapp/app/components/dashboard-agent/WatchButton.tsx new file mode 100644 index 00000000000..5b4d49e58b9 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/WatchButton.tsx @@ -0,0 +1,61 @@ +import type { WatchSpec } from "@internal/dashboard-agent-contracts"; +import { Button } from "~/components/primitives/Buttons"; +import { AGENT_ICON_ACCENT_CLASS, AgentIcon } from "./agent-identity"; +import { useDashboardAgent } from "./dashboardAgentLauncher"; + +/** + * The universal **Watch…** action (§2.1). + * + * One entry, four objects — run, queue, error, health. The ENTRY is universal and + * the RECOMMENDATION is contextual: the caller passes the spec its object + * recommends (a run → when it finishes, a queue → when it drains, an error → if + * it happens again, a degraded health report → when it recovers), and every other + * variant lives one tap deeper under **Customize**. That is why there is no + * per-object label prop worth setting and no secondary "other options" entry. + * + * Unlike `InvestigateButton` this posts NOTHING: it opens the panel with the card + * pre-filled, and an abandoned card leaves no trace in the transcript. + * + * Self-hiding, like every agent entry point: no provider (or the agent gated off) + * renders nothing, so callers need no gate of their own. Icon and accent come from + * `agent-identity`, so it reads as one family with Investigate. + */ +export function WatchButton({ + spec, + label = "Watch…", + size = "small", + variant = "secondary", + fullWidth, + className, + tooltip, +}: { + /** The recommended condition for this object, already filled in. */ + spec: WatchSpec; + label?: string; + size?: "small" | "medium"; + variant?: "primary" | "secondary" | "minimal"; + fullWidth?: boolean; + className?: string; + tooltip?: string; +}) { + const agent = useDashboardAgent(); + if (!agent) { + return null; + } + + return ( + + ); +} diff --git a/apps/webapp/app/components/dashboard-agent/WatchCard.tsx b/apps/webapp/app/components/dashboard-agent/WatchCard.tsx new file mode 100644 index 00000000000..cdf6504c7ef --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/WatchCard.tsx @@ -0,0 +1,277 @@ +/** + * The watch configuration card (§2.2) — the one thing the universal **Watch…** + * action opens. + * + * Four rules it exists to keep: + * + * 1. **One block, not three pseudo-agent messages.** It renders as a system/form + * block (`ChatSystemBlock`), because it is deterministic UI and must not wear + * the agent's voice. + * 2. **Ephemeral until submitted.** The card lives in the panel, not in the + * transcript. Abandoning it leaves no trace; validation and creation errors + * stay inside it; only a submitted outcome is persisted, by the server, as the + * `watch_result` block this card is replaced by. + * 3. **Customize expands IN PLACE.** Same block, more of it — never a second + * surface, never a modal. + * 4. **In-chat delivery is a fact, not a choice.** It is stated as a line. The + * two opt-ins beneath it are independent checkboxes and can never become a + * radio group, because there is no option to turn the chat off. + * + * PURE COMPONENT: draft in, markup and callbacks out. The draft's rules live in + * `watch-card.ts` and the wording in `watch-presentation.ts`, so this file + * decides layout and nothing else. + */ +import { EyeIcon } from "@heroicons/react/20/solid"; +import { + WATCH_WINDOW_HOURS_OPTIONS, + watchCadenceOptions, + type WatchDraft, + type WatchKind, +} from "@internal/dashboard-agent-contracts"; +import { useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Input } from "~/components/primitives/Input"; +import { AgentSpinner } from "~/components/primitives/Spinner"; +import { cn } from "~/utils/cn"; +import { ChatSystemBlock } from "./chat-layout"; +import { + variantOf, + watchDraftError, + withCadence, + withFollowUp, + withThreshold, + withVariant, + withWindow, +} from "./watch-card"; +import { + formatWatchCadence, + formatWatchWindow, + WATCH_IN_CHAT_DELIVERY_LINE, + watchConditionLabel, + watchDurationLabel, + watchSubjectLabel, +} from "./watch-presentation"; + +/** How the condition variants are named in the picker. Short, not sentences. */ +const VARIANT_LABEL: Record = { + run_start: "when it starts", + run_finished: "when it finishes", + run_failed: "if it fails", + backlog_drain: "when it drains", + queue_depth_above: "if it grows", + error_recurrence: "if it recurs", + health_recovery: "when it recovers", +}; + +/** The in-flight glyph on the submit button. Hoisted so it keeps its identity. */ +function ButtonSpinner() { + return ; +} + +/** One choice in an inline picker. Selected is the accent; the rest are quiet. */ +function Choice({ + selected, + onSelect, + children, +}: { + selected: boolean; + onSelect: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export function WatchCard({ + draft, + onChange, + onSubmit, + onCancel, + /** Start expanded — the gallery's Customize state, and a free-text pre-fill. */ + defaultExpanded = false, + /** The submit is in flight: the card stays, disabled, so nothing moves. */ + pending = false, + /** A refusal from the server (cap, duplicate, network). Stays in the card. */ + error, +}: { + draft: WatchDraft; + onChange: (draft: WatchDraft) => void; + onSubmit: () => void; + onCancel?: () => void; + defaultExpanded?: boolean; + pending?: boolean; + error?: string | null; +}) { + const [expanded, setExpanded] = useState(defaultExpanded); + const { spec } = draft; + const variant = variantOf(draft); + // Local validation first: a draft the schema would refuse never reaches the + // server, and the same sentence appears whether it was caught here or there. + const localError = watchDraftError(draft); + const blocked = localError !== null || pending; + + return ( + } + actions={ + <> + + + {onCancel ? ( + + ) : null} + + } + > + {/* The compact card, always visible: what · the condition · the duration · + where the answer lands. Four lines, in that order, expanded or not. */} +

+ Watch {watchSubjectLabel(spec)} +

+ {!expanded ? ( + <> +

{watchConditionLabel(spec)}

+

{watchDurationLabel(spec)}

+ + ) : null} +

{WATCH_IN_CHAT_DELIVERY_LINE}

+ + {expanded ? ( +
+ {/* The condition variant (§3). Only rendered where a second question + exists — the kinds with none must not show an empty picker. */} + {variant ? ( + + { + /* already the current condition */ + }} + > + {VARIANT_LABEL[spec.kind]} + + onChange(withVariant(draft, variant))}> + {VARIANT_LABEL[variant]} + + + ) : ( + + {watchConditionLabel(spec)} + + )} + + {spec.kind === "queue_depth_above" ? ( + + + onChange(withThreshold(draft, Number.parseInt(event.target.value, 10))) + } + aria-label="Queue depth threshold" + /> + + ) : null} + + + {WATCH_WINDOW_HOURS_OPTIONS.map((hours) => ( + onChange(withWindow(draft, hours))} + > + {formatWatchWindow(hours)} + + ))} + + + {/* The cadence options come from the KIND's schema limits, so an + aggregate watch can never be offered a 1-minute hot loop (§7.1). */} + + {watchCadenceOptions(spec.kind).map((minutes) => ( + onChange(withCadence(draft, minutes))} + > + {formatWatchCadence(minutes)} + + ))} + + + {/* Two INDEPENDENT opt-ins under a fixed delivery line — never a radio + group, so "email instead of chat" is not expressible (§2.2). */} + +
+ + onChange(withFollowUp(draft, { investigateOnAttention: checked })) + } + /> + onChange(withFollowUp(draft, { notifyExternally: checked }))} + /> +
+
+
+ ) : null} + + {/* Errors live and die with the card: nothing is persisted, and the user + fixes the draft in place rather than starting again (§2.2 step 5). */} + {localError || error ? ( +

{localError ?? error}

+ ) : null} +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/WatchChips.tsx b/apps/webapp/app/components/dashboard-agent/WatchChips.tsx new file mode 100644 index 00000000000..8275cbd0940 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/WatchChips.tsx @@ -0,0 +1,139 @@ +/** + * The watch chip row. One chip per watch, its state carried by a coloured icon, + * with a cancel affordance on the active ones only. + * + * A chip has to answer "what is being watched, and is it still watching?" at a + * glance in a 380px panel — hence the short label plus a state icon, with the + * note and cadence in a tooltip rather than on screen. The label text keeps the + * default colour: only the icon is coloured, the same rule the run status cells + * follow. + */ +import { + CheckCircleIcon, + ClockIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + InformationCircleIcon, + NoSymbolIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; +import type { + WatchObservedOutcome, + WatchResolution, + WatchSemanticIcon, + WatchStatus, +} from "@internal/dashboard-agent-contracts"; +import { AgentSpinner } from "~/components/primitives/Spinner"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { cn } from "~/utils/cn"; +import { type AgentTone, TONE_ICON_COLOR } from "./agent-badges"; +import { wakePresentation } from "./WakeBanner"; +import { watchChipLabel, watchChipTooltip } from "./watch-chips"; + +/** One watch, as the panel's loader hands it over (dates already JSON strings). */ +export type WatchChip = { + id: string; + identity: string; + status: WatchStatus; + kind: string; + note: string; + checkEveryMinutes: number; + expiresAt: string; + /** Last check's reason — the wake banner distinguishes terminal_unsatisfied. */ + endedReason?: string | null; + /** How the watch ended. Null while active; absent on pre-resolution rows. */ + resolution?: WatchResolution | null; + /** What the resolving check observed — the other half of the terminal icon. */ + observedOutcome?: WatchObservedOutcome | null; +}; + +/** + * Semantic icon → glyph, the same table the wake banner draws from. Which icon a + * resolved result deserves is decided in contracts; this only owns the glyph set. + */ +const SEMANTIC_ICON: Record JSX.Element> = { + success: CheckCircleIcon, + attention: ExclamationTriangleIcon, + error: ExclamationCircleIcon, + waiting: ClockIcon, + info: InformationCircleIcon, +}; + +/** + * A terminal chip wears the RESOLVED RESULT's icon, not its lifecycle status + * (§4.2, binding): a `run_finished` watch on a run that failed resolved + * `condition_met` and would otherwise be shown as a green check. The banner and + * the chip therefore agree by construction — both render the same presentation. + * + * Cancellation is the exception, and deliberately so: it has no resolution and + * never will (it is the one silent exit), so it keeps its own glyph. + */ +function StatusIcon({ watch }: { watch: WatchChip }) { + // Same choice as an executing run: a spinner is the "still going" state. + if (watch.status === "active") return ; + + if (watch.status === "cancelled") { + return ; + } + + const presentation = wakePresentation(watch.status === "fired" ? "fired" : "expired", watch); + const Icon = SEMANTIC_ICON[presentation.semanticIcon]; + return ( + + ); +} + +export function WatchChips({ + watches, + onCancel, +}: { + watches: WatchChip[]; + /** Stop watching. Only offered on an active watch. */ + onCancel?: (watchId: string) => void; +}) { + if (watches.length === 0) return null; + + return ( +
+ watches + {watches.map((watch) => { + const label = watchChipLabel(watch); + return ( + + + {label}} + /> + {watch.status === "active" && onCancel ? ( + onCancel(watch.id)} + className="text-text-faint transition-colors hover:text-error focus-visible:text-error focus-custom" + > + + + } + /> + ) : null} + + ); + })} +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx new file mode 100644 index 00000000000..a7623f3e4c7 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx @@ -0,0 +1,55 @@ +/** + * What a submitted watch card leaves in the transcript (§2.2, binding). + * + * Two flavours, one block: + * + * - **Confirmation** — a watch is running. It states the four lifetime facts + * (what · how often it checks · that it reports once · when it gives up) and it + * is the ONLY transcript record of the request: there is no separate "please + * watch this" line above it, because that line would say the same thing twice. + * - **One-shot result** — the immediate check answered outright, so no watch was + * created (§4.1). No chip will appear, no wake will arrive, and there is + * nothing to cancel. + * + * PURE COMPONENT: props in, markup out, so the panel and the gallery render it + * from the same payload. The wording is not computed here — it was FROZEN into + * the block at append time by `watch-presentation.ts`, the same way a resolved + * watch's facts are frozen (§7.5), so a later copy change never rewrites what a + * user was already told. + */ +import { CheckCircleIcon, EyeIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; +import type { WatchResultBlock as WatchResultBlockPayload } from "@internal/dashboard-agent-contracts"; +import { ChatSystemBlock } from "./chat-layout"; +import { TONE_ICON_COLOR } from "./agent-badges"; +import { cn } from "~/utils/cn"; + +/** + * Icon and label per outcome. A confirmation is not "success" — nothing has + * happened yet — so it wears the neutral watching eye rather than a check; the + * check belongs to the one-shot that really did answer the question. + */ +const OUTCOME = { + watching: { label: "Watch", Icon: EyeIcon, tone: "neutral" }, + already_true: { label: "Watch", Icon: CheckCircleIcon, tone: "success" }, + impossible: { label: "Watch", Icon: InformationCircleIcon, tone: "neutral" }, +} as const; + +export function WatchResultBlock({ block }: { block: WatchResultBlockPayload }) { + const { label, Icon, tone } = OUTCOME[block.outcome] ?? OUTCOME.watching; + + return ( + } + > +

{block.headline}

+ {block.lifetime ?

{block.lifetime}

: null} + {block.detail ?

{block.detail}

: null} + {(block.followUp ?? []).map((line) => ( +

+ {line} +

+ ))} +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx b/apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx new file mode 100644 index 00000000000..fb81c226053 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/WatchWakeToast.tsx @@ -0,0 +1,155 @@ +/** + * #13 The dashboard-wide signal that a watch woke a chat while the panel was + * closed. The launcher's dot is easy to miss, so a wake also raises a toast. + * + * Persistent by design: a wake is the answer to a question the user asked + * minutes or hours ago, so it waits until it's dismissed rather than expiring on + * a 5s timer. Dismissing does NOT mark the chat read — reading happens in the + * panel, so the dot survives a swatted toast. + * + * The content is the standard `Callout` in its `agent` variant (the launcher's + * chat icon, the agent's indigo accent), inside the sonner shell the app's other + * toasts use — so a wake looks like everything else the dashboard says, not like + * a one-off panel. + */ +import { XMarkIcon } from "@heroicons/react/20/solid"; +import { toast } from "sonner"; +import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import type { WatchObservedOutcome, WatchResolution } from "@internal/dashboard-agent-contracts"; +import { wakeResolution } from "./WakeBanner"; +import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "./watch-presentation"; + +/** Matches sonner's default toast width, same as the app's other toasts. */ +const TOAST_WIDTH = 356; + +/** More new wakes than this at once collapse into one summary toast. */ +export const WAKE_TOAST_MAX_INDIVIDUAL = 3; + +export type WatchWake = { + watchId: string; + chatId: string; + /** The wire encoding off the row (§7.5). Not the outcome — see `resolution`. */ + outcome: "fired" | "expired"; + note: string; + /** + * What actually happened, frozen on the row by the resolving check. The toast + * states the FACT ("email-sends queue drained"), not "Watch update" — same + * headline the banner and the email use, from the same presenter, so the three + * can never disagree. Absent on a row written before the resolution model, and + * the presenter falls back rather than guessing. + */ + kind?: string; + identity?: string; + resolution?: WatchResolution | null; + observedOutcome?: WatchObservedOutcome | null; +}; + +/** + * The toast's title: the fact, or the neutral fallback when this wake predates + * the resolution model. Never a kind-specific sentence written here — the + * wording is `watch-presentation.ts`'s, and this only decides which watch to ask + * it about. + */ +export function watchWakeToastTitle(wake: WatchWake): string { + if (!wake.kind || !wake.identity) return WATCH_PRESENTATION_FALLBACK.headline; + return presentResolvedWatch({ + kind: wake.kind, + identity: wake.identity, + resolution: wakeResolution(wake.outcome, { resolution: wake.resolution ?? null }), + observed: wake.observedOutcome ?? null, + }).headline; +} + +function WakeToastUI({ + t, + title, + message, + onOpenChat, +}: { + t: string; + title: string; + message: string; + onOpenChat: () => void; +}) { + return ( + // Opaque base under the callout: a callout is translucent by design, and a + // toast has to read over whatever page is behind it. +
+ toast.dismiss(t)} + > + + + } + > +
+ {title} + {message} + +
+
+
+ ); +} + +function show(node: (t: string) => React.ReactElement, id: string) { + toast.custom((t) => node(t as string), { + // Manual dismissal only — see the file comment. + duration: Infinity, + // Keyed so a re-render or a duplicate poll can't stack the same wake twice. + id, + }); +} + +/** + * One persistent toast for a single wake. `onOpenChat` is given the chat the wake + * happened in — the toast is about that conversation, so it must open that one + * rather than whichever chat the panel had last. + */ +export function showWatchWakeToast(wake: WatchWake, onOpenChat: (chatId: string) => void) { + show( + (t) => ( + onOpenChat(wake.chatId)} + /> + ), + `watch-wake-${wake.watchId}` + ); +} + +/** One persistent toast standing in for a batch too large to narrate one by one. */ +export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void) { + show( + (t) => ( + + ), + // One id for all summaries: a later poll rewrites the count in place instead + // of stacking a second never-expiring toast on top of the first. + "watch-wakes-summary" + ); +} diff --git a/apps/webapp/app/components/dashboard-agent/chat-layout.test.ts b/apps/webapp/app/components/dashboard-agent/chat-layout.test.ts index 257d0af9515..c58837cd822 100644 --- a/apps/webapp/app/components/dashboard-agent/chat-layout.test.ts +++ b/apps/webapp/app/components/dashboard-agent/chat-layout.test.ts @@ -90,6 +90,7 @@ describe("chat-layout enforcement", () => { "ChatToolRow", "ChatNote", "ChatStatusLine", + "ChatWakeSlot", "ChatActionsRow", ]) { expect(source, name).toContain(`export function ${name}(`); diff --git a/apps/webapp/app/components/dashboard-agent/chat-layout.tsx b/apps/webapp/app/components/dashboard-agent/chat-layout.tsx index 4e73f3d7dcb..3f4c2ce0bc7 100644 --- a/apps/webapp/app/components/dashboard-agent/chat-layout.tsx +++ b/apps/webapp/app/components/dashboard-agent/chat-layout.tsx @@ -25,6 +25,10 @@ * - `ChatPendingTool` — a tool call still in flight: a bare spinner line * - `ChatToolRow` — a tool-call row, optionally with progress under it * - `ChatNote` — an inline system / interceptor note + * - `ChatSystemBlock` — a deterministic form/system block (the watch card and + * the confirmation it becomes): canned, honestly canned + * - `ChatWakeSlot` — an unprompted turn: its banner and the narration under + * it, kept together as one unit * - `ChatStatusLine` — an icon and one line of status * - `ChatActionsRow` — a row of buttons * @@ -64,8 +68,10 @@ const TURN_GAP = "space-y-4"; const TURN_BODY_GAP = "space-y-2"; /** Gap inside a single-line row (icon to text, button to button). */ const ROW_GAP = "gap-2"; -/** Gap inside a chip (icon to label). Tighter than a row. */ +/** Gap inside a chip (icon to label). Tighter than a row — same as a watch chip. */ const CHIP_GAP = "gap-1.5"; +/** Rhythm inside one unit — a banner and the text it introduces. */ +const UNIT_GAP = "space-y-1.5"; const SCROLLER = "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"; @@ -228,9 +234,9 @@ export function ChatProgress({ children }: { children: React.ReactNode }) { * * It replaces the tool row for the whole in-flight phase, so the transcript never * shows a half-streamed blob of input JSON that then flips to a card. The pill is - * deliberately the smallest thing that fits the transcript's chip language — when - * the call lands, whatever the result renders as takes its place, and the jump is - * one line high. + * deliberately the smallest thing that fits the transcript's chip language (the + * watch chips are its sibling) — when the call lands, whatever the result renders + * as takes its place, and the jump is one line high. */ export function ChatPendingTool({ label }: { label: string }) { const insetClass = useInsetClass(); @@ -296,6 +302,77 @@ export function ChatStatusLine({ ); } +/** + * An unprompted turn: the banner that says what woke the chat, then the body it + * introduces — tighter than the gap between two independent micro-layouts, + * because the two read as one thing. + */ +export function ChatWakeSlot({ + banner, + children, +}: { + banner: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+ {banner} + {children} +
+ ); +} + +/** Rhythm between the lines inside a system block. Tighter than a turn body. */ +const BLOCK_LINE_GAP = "space-y-1"; +/** The system block's own padding. Owned here, never set by its contents. */ +const BLOCK_INSET = "px-3 py-2.5"; + +/** + * A deterministic **system/form block** — the third voice in the transcript. + * + * "Who is speaking" is binding (design §2.2): the agent's voice belongs only + * where the model actually ran. A watch configuration card and the confirmation + * it becomes are canned UI, so they must read as canned — a bordered block with a + * micro-label, not prose in the agent's typography and not one of the rich cards + * (those are answers; this is a form). + * + * Like every other micro-layout: the frame, the inset and the internal rhythm are + * the library's, the contents are the consumer's. `actions` is the footer row, so + * a consumer never has to reach for `ChatActionsRow` and its own top margin. + */ +export function ChatSystemBlock({ + label, + icon, + children, + actions, +}: { + /** The micro-label that says this is the system, not the agent. */ + label: string; + /** Colour it at the call site — the state lives in the icon, not the text. */ + icon?: React.ReactNode; + children: React.ReactNode; + actions?: React.ReactNode; +}) { + return ( +
+
+ {icon} + + {label} + +
+
{children}
+ {actions ? {actions} : null} +
+ ); +} + /** A row of buttons — a card's footer intents, a retry, a dismiss. */ export function ChatActionsRow({ children }: { children: React.ReactNode }) { return
{children}
; diff --git a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx index a59d6d591cd..b0595a803b5 100644 --- a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx +++ b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx @@ -1,3 +1,4 @@ +import type { WatchSpec } from "@internal/dashboard-agent-contracts"; import { createContext, useContext } from "react"; import { Button } from "~/components/primitives/Buttons"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; @@ -25,6 +26,18 @@ type DashboardAgentContextValue = { * that's already open (so an in-progress conversation is never hijacked). */ openWith: (text: string) => void; + /** + * Open the panel with a watch CARD pre-filled — the universal `Watch…` entry + * (§2.1). Deliberately not `openWith`: a card is not a message. Nothing is + * posted to the transcript and nothing is persisted until the card is + * submitted, so an abandoned card leaves no trace. + */ + openWithWatch: (spec: WatchSpec) => void; + /** + * Watch wakes the user hasn't seen. Polled only while the panel is closed — + * with it open the chat itself is the notification, so this stays at 0. + */ + unreadWakes: number; }; const DashboardAgentContext = createContext(null); @@ -43,13 +56,15 @@ export function DashboardAgentLauncher() { return null; } - const { open, setOpen } = agent; + const { open, setOpen, unreadWakes } = agent; // The open panel has its own Close button and Esc — a second toggle in the // page header would just be noise. if (open) { return null; } + const hasUnread = unreadWakes > 0; + return ( } button={ - + + + {hasUnread && ( + + )} + } /> ); diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoReportCard.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoReportCard.tsx index 1004f5a1916..81b279fa8e5 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/components/DemoReportCard.tsx +++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoReportCard.tsx @@ -20,6 +20,8 @@ import type { } from "~/presenters/v3/reports/report-view-model"; import { healthMessages } from "~/presenters/v3/reports/health/health-messages"; import { + FOOTER_WATCH_CODE, + FOOTER_WATCH_ONLY_CODE, ReportBody, ReportCard, ReportFindingLine, @@ -315,6 +317,27 @@ export function DemoReportCard({ }; }); + // Same offer the shipped card makes when there is something to recover from. + if (severity !== "ok") { + const offersControl = vm.footer.some((entry) => { + const style = reportFooterStyle(entry.code); + return style === "action" || style === "docs"; + }); + const label = offersControl ? "Watch recovery" : "watch it recover"; + const watchItem = { + code: offersControl ? FOOTER_WATCH_CODE : FOOTER_WATCH_ONLY_CODE, + node: onAction?.(label)}>{label}, + }; + // In the actions list the watch joins the buttons before the "or do + // nothing" prose; in the stale sentence it stays last. + const noteIndex = footerItems.findIndex((item) => reportFooterStyle(item.code) === "note"); + if (offersControl && noteIndex !== -1) { + footerItems.splice(noteIndex, 0, watchItem); + } else { + footerItems.push(watchItem); + } + } + // Docs the report cites — demo mode has no host to resolve `trigger://` URIs, // so only real URLs. for (const link of vm.links) { diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoWatchChips.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoWatchChips.tsx new file mode 100644 index 00000000000..5a707861dbb --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoWatchChips.tsx @@ -0,0 +1,98 @@ +/** + * The watch chip row. One chip per watch, its state carried by a coloured icon, + * with a cancel affordance on the active ones only. + * + * A chip has to answer "what is being watched, and is it still watching?" at a + * glance in a 380px panel — hence the short label plus a state icon, with the + * note and cadence in the title attribute rather than on screen. The label text + * keeps the default colour: only the icon is coloured, the same rule the run + * status cells follow. + */ +import { CheckCircleIcon, ClockIcon, NoSymbolIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import type { WatchStatus } from "@internal/dashboard-agent-contracts"; +import { AgentSpinner } from "~/components/primitives/Spinner"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { cn } from "~/utils/cn"; +import { type AgentTone, TONE_ICON_COLOR } from "../../agent-badges"; +import type { DemoWatch } from "../fixtures/watches"; + +const STATUS_TONE: Record = { + active: "neutral", + fired: "success", + expired: "neutral", + cancelled: "neutral", +}; + +const STATUS_LABEL: Record = { + active: "watching", + fired: "fired", + expired: "expired", + cancelled: "cancelled", +}; + +function StatusIcon({ status }: { status: WatchStatus }) { + const className = cn("size-3.5 shrink-0", TONE_ICON_COLOR[STATUS_TONE[status]]); + switch (status) { + case "active": + // Same choice as an executing run: a spinner is the "still going" state. + return ; + case "fired": + return ; + case "expired": + return ; + case "cancelled": + return ; + } +} + +export function DemoWatchChips({ + watches, + onCancel, +}: { + watches: DemoWatch[]; + /** Demo interceptor. Never cancels anything — reports what would happen. */ + onCancel?: (watch: DemoWatch) => void; +}) { + if (watches.length === 0) return null; + + return ( +
+ watches + {watches.map((watch) => ( + + + {watch.chipLabel}} + /> + {watch.cancellable ? ( + onCancel?.(watch)} + className="text-text-faint transition-colors hover:text-error focus-visible:text-error focus-custom" + > + + + } + /> + ) : null} + + ))} +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts b/apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts index 5bdcb5bd85c..e0238bf9c04 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/demo-chats.ts @@ -36,6 +36,8 @@ import { demoPageContexts, demoPromptSets, demoShowCodeMarkdown, + demoWatchNarration, + demoWatches, failedToolPart, pendingToolPart, reasoningPart, @@ -47,10 +49,11 @@ import { userMessage, type DemoIntent, type DemoInvestigation, + type DemoWatch, } from "./fixtures"; /** The flows the playbook is organised by. */ -export type DemoFlow = "investigate" | "navigation" | "prompts" | "reports" | "base"; +export type DemoFlow = "investigate" | "navigation" | "prompts" | "watch" | "reports" | "base"; export type DemoItem = /** Real messages, rendered by the production message renderer. */ @@ -59,6 +62,7 @@ export type DemoItem = | { kind: "report"; report: ReportViewModel; sourceUri?: string } | { kind: "chart"; title?: string } | { kind: "intent"; intent: DemoIntent } + | { kind: "watches"; watches: DemoWatch[] } | { kind: "prompts"; prompts: SuggestedPrompt[]; @@ -94,6 +98,8 @@ export type DemoChat = { draft?: string; /** Context banner for this chat. Defaults to the panel's real one. */ banner?: { projectSlug: string; environmentSlug: string; currentPage: string }; + /** Watch chips shown under the banner, as the panel header would. */ + headerWatches?: DemoWatch[]; /** Marks the transcript as replayed from the store rather than live. */ resumed?: boolean; lastMessageAt: string; @@ -514,6 +520,102 @@ const promptsPageAware: DemoChat = { ], }; +// --------------------------------------------------------------------------- +// Watch +// --------------------------------------------------------------------------- + +const watchCreatedAndWake: DemoChat = { + id: demoId("watch-created-and-wake"), + title: "Tell me when the backlog drains", + flow: "watch", + summary: + "The full watch arc: created from a conversation with the cadence stated out loud, shown as a chip, speaking unprompted when it fires, then offering the next watch worth having.", + banner: { ...PROD_BANNER, currentPage: "Run detail" }, + headerWatches: demoWatches.activeRow, + lastMessageAt: "2026-07-27T10:19:30.000Z", + items: [ + { + kind: "messages", + messages: [userMessage("watch-q", "Tell me when the retry finishes.")], + }, + { kind: "intent", intent: demoIntents.watch }, + { + kind: "messages", + messages: [ + assistantMessage("watch-created", [ + textPart( + `Watching \`${DEMO_WORLD.failedRunId}\` — I'll check every minute for up to 2 hours and tell you the moment it settles, whichever way it goes. You'll also see it as a chip at the top of the panel until then, and you can cancel it from there. I only speak once per watch, so it won't repeat itself.` + ), + ]), + ], + }, + { kind: "watches", watches: demoWatches.activeRow }, + { + kind: "note", + text: "Everything below arrived on its own, minutes later — no user turn in between.", + }, + { + kind: "messages", + messages: [assistantMessage("watch-wake", [textPart(demoWatchNarration.wake)])], + }, + { kind: "watches", watches: [demoWatches.errorRecurrence] }, + { + kind: "messages", + messages: [ + assistantMessage("watch-next", [ + textPart( + `Two things you might want next: the 40 remaining runs from the 09:02 burst are still queued behind \`${DEMO_WORLD.queue}\`'s concurrency limit, and the rate-limit error itself is worth a watch for the next 12 hours in case the fix didn't take. Say the word for either.` + ), + ]), + ], + }, + ], +}; + +const watchExpiryAndCancel: DemoChat = { + id: demoId("watch-expiry-and-cancel"), + title: "Watch for that error recurring", + flow: "watch", + summary: + "Three endings: expired having verified nothing happened, expired unable to verify at all, and cancelled from the chip.", + banner: { ...PROD_BANNER, currentPage: "Queues" }, + headerWatches: demoWatches.row, + lastMessageAt: "2026-07-27T15:02:00.000Z", + items: [ + { + kind: "messages", + messages: [ + assistantMessage("watch-row-intro", [ + textPart( + `Four watches from this conversation, in every state they can end in. One is still live on \`${DEMO_WORLD.failedRunId}\`; the rest have finished, and each one said so exactly once — below, in the order they spoke.` + ), + ]), + ], + }, + { kind: "watches", watches: demoWatches.row }, + { + kind: "messages", + messages: [assistantMessage("watch-expiry", [textPart(demoWatchNarration.expiry)])], + }, + { + kind: "note", + text: "The variant that matters most: the watch could not check its condition, and says so instead of implying an answer.", + }, + { + kind: "messages", + messages: [ + assistantMessage("watch-expiry-unverified", [ + textPart(demoWatchNarration.expiryUnverified), + ]), + ], + }, + { + kind: "messages", + messages: [assistantMessage("watch-cancelled", [textPart(demoWatchNarration.cancelled)])], + }, + ], +}; + // --------------------------------------------------------------------------- // Reports // --------------------------------------------------------------------------- @@ -902,6 +1004,8 @@ export const demoChats: DemoChat[] = [ navigateFilteredRuns, navigateRejectedIntent, promptsPageAware, + watchCreatedAndWake, + watchExpiryAndCancel, reportHealthy, reportDegraded, docsAnswer, diff --git a/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts index e4d226491d3..7e2d42de942 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts @@ -6,6 +6,8 @@ import { safeParseTriggerUri, suggestedPromptSchema, viewBlockSchema, + watchIdentity, + watchSpecSchema, SUGGESTED_PROMPT_CAP, type Evidence, } from "@internal/dashboard-agent-contracts"; @@ -59,13 +61,16 @@ describe("demo ids", () => { } }); - it("namespaces investigation, hypothesis and prompt 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); @@ -196,6 +201,33 @@ describe("investigation fixtures", () => { }); }); +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)) { @@ -275,7 +307,9 @@ describe("chart fixtures", () => { describe("demo coverage", () => { it("covers every v1 flow", () => { const flows = new Set(demoChats.map((chat) => chat.flow)); - expect(flows).toEqual(new Set(["investigate", "navigation", "prompts", "reports", "base"])); + expect(flows).toEqual( + new Set(["investigate", "navigation", "prompts", "watch", "reports", "base"]) + ); }); it("covers the base panel states", () => { diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts index 2b27c0d993d..aeb836b30c6 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts @@ -9,3 +9,4 @@ export * from "./investigation"; export * from "./messages"; export * from "./page-context"; export * from "./reports"; +export * from "./watches"; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts index 9b6c654a6b8..84e0297d1d5 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts @@ -10,6 +10,7 @@ */ import { isExecutableIntent, type AgentIntent } from "@internal/dashboard-agent-contracts"; import { DEMO_WORLD, demoRunUri } from "../ids"; +import { demoBacklogDrainWatch } from "./watches"; export type DemoIntent = { intent: AgentIntent; @@ -52,10 +53,16 @@ export const demoNavigateToRun = demoIntent( /** Hand a follow-up question back into the conversation. */ export const demoAskIntent = demoIntent( - { kind: "ask", prompt: "Do you want me to compare this run against a healthy one?" }, + { kind: "ask", prompt: "Do you want me to watch the retry and tell you when it finishes?" }, "Asked a follow-up" ); +/** Start a watch. */ +export const demoWatchIntent = demoIntent( + { kind: "watch", spec: demoBacklogDrainWatch.spec }, + `Watching ${DEMO_WORLD.backlogQueue} · checking every 5 min for up to 6h` +); + /** * RESERVED until write actions ship. Kept as a fixture so the mockup shows the * host *rejecting* it explicitly rather than silently ignoring it — that @@ -70,5 +77,6 @@ export const demoIntents = { navigateToFailedRuns: demoNavigateToFailedRuns, navigateToRun: demoNavigateToRun, ask: demoAskIntent, + watch: demoWatchIntent, proposeFix: demoProposeFixIntent, } as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts index 291528449c8..acf72b84508 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts @@ -176,9 +176,9 @@ export const demoPromptSets: Record = { "contextual" ), prompt( - "retry-status", - "Did the retry work?", - `How did the retry of ${DEMO_WORLD.failedRunId} go?`, + "watch-retry", + "Tell me when it retries", + `Watch ${DEMO_WORLD.failedRunId} and tell me when it finishes.`, "contextual" ), DEFAULT_PROMPTS[1]!, @@ -236,9 +236,9 @@ export const demoPromptSets: Record = { "promoted" ), prompt( - "recent-occurrences", - "How often is this happening?", - "How often has this error happened recently, and is it still happening?", + "watch-recurrence", + "Tell me if it comes back", + "Watch this error and tell me if it happens again.", "contextual" ), DEFAULT_PROMPTS[1]!, @@ -271,7 +271,7 @@ export const demoPromptSets: Record = { }; /** Chips the user has dismissed — the row must not offer them again. */ -export const demoDismissedPromptIds: string[] = [demoId("prompt-retry-status")]; +export const demoDismissedPromptIds: string[] = [demoId("prompt-watch-retry")]; /** What the row shows after the dismissal above, still capped. */ export const demoPromptsAfterDismissal: SuggestedPrompt[] = demoPromptSets.failedRun diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts new file mode 100644 index 00000000000..87d5bfed13a --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts @@ -0,0 +1,183 @@ +/** + * Watch fixtures — specs typed against the contracts package, plus the chip + * state the panel shows for each and the narration the agent writes when a + * watch wakes, expires, or can't verify its condition. + * + * The chip is the only always-visible piece of a watch, so it carries its own + * state (`active` / `fired` / `expired` / `cancelled`) straight from + * `watchStatuses`, and its identity comes from `watchIdentity(spec)` — the same + * dedupe key the host uses, so two chips can never disagree with the store + * about whether they watch the same thing. + */ +import { + watchIdentity, + type WatchSpec, + type WatchStatus, +} from "@internal/dashboard-agent-contracts"; +import { DEMO_WORLD, demoId } from "../ids"; + +export type DemoWatch = { + /** Demo-namespaced watch id. */ + id: string; + spec: WatchSpec; + status: WatchStatus; + /** Dedupe identity, derived — never hand-written. */ + identity: string; + /** Short chip label, e.g. "backlog-drain". */ + chipLabel: string; + /** When it was created / when it will expire, for the chip tooltip. */ + createdAt: string; + expiresAt: string; + /** Whether the chip offers a cancel affordance. Only an active watch does. */ + 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", +}); + +/** Active: waiting for a specific run to finish. Run-state cadence — 1 minute is legal. */ +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" +); + +/** Active: aggregate condition, so the cadence floor is 5 minutes. */ +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" +); + +/** Fired: the condition happened and the user has been told. */ +export const demoErrorRecurrenceWatch = watch( + "email-sends", + { + kind: "error_recurrence", + fingerprint: DEMO_WORLD.errorFingerprint, + 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" +); + +/** Expired without ever being satisfied. */ +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" +); + +/** Cancelled by the user from the chip. */ +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" +); + +/** The chip row as it looks with watches in every state at once. */ +export const demoWatchRow: DemoWatch[] = [ + demoRunFinishedWatch, + demoBacklogDrainWatch, + demoErrorRecurrenceWatch, + demoHealthRecoveryWatch, + demoCancelledWatch, +]; + +/** Just the live ones — the normal case the header shows. */ +export const demoActiveWatchRow: DemoWatch[] = [demoRunFinishedWatch, demoBacklogDrainWatch]; + +// --------------------------------------------------------------------------- +// Narration. A watch speaks exactly once per outcome, in the chat, unprompted — +// so the wording carries the whole burden of explaining why a message appeared. +// --------------------------------------------------------------------------- + +export const demoWatchNarration = { + /** The watch fired: say what happened, what it means, and stop watching. */ + 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.`, + + /** Expired having verified the condition never happened. */ + 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.`, + + /** + * Expired unable to verify. Different failure mode from the above and it must + * not be dressed up as an answer: the watch could not check, so it says so. + */ + 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 from the chip. Short, no narration theatre. */ + cancelled: `Stopped watching \`${DEMO_WORLD.waitingRunId}\`.`, +} as const; + +export const demoWatches = { + runFinished: demoRunFinishedWatch, + backlogDrain: demoBacklogDrainWatch, + errorRecurrence: demoErrorRecurrenceWatch, + healthRecovery: demoHealthRecoveryWatch, + cancelled: demoCancelledWatch, + row: demoWatchRow, + activeRow: demoActiveWatchRow, + narration: demoWatchNarration, +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/ids.ts b/apps/webapp/app/components/dashboard-agent/demo/ids.ts index 479cce3c35c..60b9516fea1 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/ids.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/ids.ts @@ -5,7 +5,7 @@ * * Two conventions, on purpose: * - * 1. **Our own namespace** (chats, investigations) uses the literal + * 1. **Our own namespace** (chats, investigations, watches) uses the literal * `demo:` prefix, so `isDemoChatId` answers "this id is a fixture, never * talk to the server about it". * 2. **Resource ids** (runs, queues, errors, deployments, source shas) keep @@ -26,7 +26,7 @@ export const DEMO_ID_PREFIX = "demo:"; /** The marker every demo id — ours or resource-shaped — must contain. */ export const DEMO_MARKER = "demo"; -/** `demo:` + the rest. Use for chats and investigations. */ +/** `demo:` + the rest. Use for chats, investigations and watches. */ export function demoId(rest: string): string { return `${DEMO_ID_PREFIX}${rest}`; } diff --git a/apps/webapp/app/components/dashboard-agent/demo/index.ts b/apps/webapp/app/components/dashboard-agent/demo/index.ts index 0810311a656..31a12bf5563 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/index.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/index.ts @@ -24,3 +24,4 @@ export { DemoIntentBubble, DemoNote } from "./components/DemoIntentBubble"; export { DemoInvestigationCard } from "./components/DemoInvestigationCard"; export { DemoReportCard } from "./components/DemoReportCard"; export { DemoSuggestedPromptsRow } from "./components/DemoSuggestedPromptsRow"; +export { DemoWatchChips } from "./components/DemoWatchChips"; diff --git a/apps/webapp/app/components/dashboard-agent/list-row.tsx b/apps/webapp/app/components/dashboard-agent/list-row.tsx index fcd089a2882..4400396bfbb 100644 --- a/apps/webapp/app/components/dashboard-agent/list-row.tsx +++ b/apps/webapp/app/components/dashboard-agent/list-row.tsx @@ -33,6 +33,7 @@ export function AgentListRow({ meta, status, variant = "default", + unread = false, onSelect, action, }: { @@ -46,6 +47,11 @@ export function AgentListRow({ */ status?: ReactNode; variant?: AgentListRowVariant; + /** + * Something happened here the user hasn't seen. Brightens the label and adds + * the same indigo dot the launcher uses, so the two read as one signal. + */ + unread?: boolean; onSelect: () => void; /** Hover-revealed control, e.g. dismiss or delete. Use {@link AgentListRowAction}. */ action?: ReactNode; @@ -57,12 +63,19 @@ export function AgentListRow({ onClick={onSelect} className={cn( "flex min-w-0 flex-1 items-center gap-2 rounded-md border px-3 py-2 text-left text-sm outline-hidden transition focus-custom", - ROW_VARIANTS[variant] + ROW_VARIANTS[variant], + unread && "text-text-bright" )} > {status ? ( {status} ) : null} + {unread ? ( + <> + + Unread. + + ) : null} {label} {meta ? {meta} : null} diff --git a/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx b/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx index 1822183e177..db0af2d3afd 100644 --- a/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx +++ b/apps/webapp/app/components/dashboard-agent/report-sparkline.tsx @@ -341,6 +341,15 @@ export function reportFooterStyle(code: string): ReportFooterStyle { return "action"; } +/** + * The footer's recovery-watch offer, which no report emits as a footer entry — + * the card adds it. Two codes because it is phrased two ways: as an addendum to + * actions the user was just given, and as the only thing on offer when the + * report has nothing for them to do. + */ +export const FOOTER_WATCH_CODE = "watch_recovery"; +export const FOOTER_WATCH_ONLY_CODE = "watch_recovery_only"; + /** * A dimmed line that accompanies a row entry — prose the old sentence footer * carried around the control, kept as a note under the row. diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/registry.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/registry.ts index 2db1df58608..6d44f61beca 100644 --- a/apps/webapp/app/components/dashboard-agent/suggested-prompts/registry.ts +++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/registry.ts @@ -8,13 +8,13 @@ * - `investigate` — "something is wrong, dig in". Filled by a fresh_failure or * slow_run signal, or by the page kind when the page is inherently about a * failure (an error or a single run). - * - `status` — "what's going on with this right now". Filled by a waiting_run - * or saturation signal, or by a queue/error page. + * - `watch` — "tell me when this changes". Filled by a waiting_run or + * saturation signal, or by a queue/error page. * - `explain` — the evergreen explain/find/show question. Always present. * - `docs` — a doc-flavored "how do I …". Always present, always last. * * A signal only exists for abnormal state (the route `handle` mappers enforce - * that), so an `investigate`/`status` chip appearing is itself the news. Wording + * that), so an `investigate`/`watch` chip appearing is itself the news. Wording * follows the demo fixtures (`demo/fixtures/page-context.ts`), which is the * review-approved copy. * @@ -50,7 +50,7 @@ const def = (id: string, label: string, prompt: string) => make(id, label, promp const ctx = (id: string, label: string, prompt: string) => make(id, label, prompt, "contextual"); /** The slots after the promoted one, in display order. */ -export const PROMPT_SLOTS = ["investigate", "status", "explain", "docs"] as const; +export const PROMPT_SLOTS = ["investigate", "watch", "explain", "docs"] as const; export type PromptSlot = (typeof PROMPT_SLOTS)[number]; @@ -60,7 +60,7 @@ export type PromptSlot = (typeof PROMPT_SLOTS)[number]; */ export type PageSlotPrompts = { investigate?: SuggestedPrompt; - status?: SuggestedPrompt; + watch?: SuggestedPrompt; explain: SuggestedPrompt; docs: SuggestedPrompt; }; @@ -131,10 +131,10 @@ export function pageSlotPrompts(page: AgentPage): PageSlotPrompts { "What's causing this error?", "Investigate this error — what's causing it and which runs are affected?" ), - status: def( - "error-recent-occurrences", - "How often is this happening?", - "How often has this error happened recently, and is it still happening?" + watch: def( + "error-watch-recurrence", + "Tell me if it comes back", + "Watch this error and tell me if it happens again." ), explain: def( "error-similar", @@ -150,10 +150,10 @@ export function pageSlotPrompts(page: AgentPage): PageSlotPrompts { case "queue": return { - status: def( - "queue-backlog", - "How big is the backlog?", - `How big is the backlog on the ${page.name} queue, and is it clearing?` + watch: def( + "queue-watch-drain", + "Tell me when the backlog drains", + `Watch the ${page.name} queue and tell me when the backlog drains.` ), explain: def( "queue-state", @@ -205,8 +205,8 @@ export function pageDefaultPrompts(page: AgentPage): SuggestedPrompt[] { export const SIGNAL_SLOT: Record = { fresh_failure: "investigate", slow_run: "investigate", - waiting_run: "status", - concurrency_saturation: "status", + waiting_run: "watch", + concurrency_saturation: "watch", }; /** @@ -255,10 +255,10 @@ export function promptForSignal(signal: AgentPageSignal, now: number): Suggested case "waiting_run": return ctx( "waiting-run", - "Why hasn't this run started?", + "Tell me when this run starts", signal.queue - ? `Why is ${signal.runId} still waiting in the ${signal.queue} queue?` - : `Why is ${signal.runId} still waiting to start?` + ? `Watch ${signal.runId} and tell me when it leaves the ${signal.queue} queue.` + : `Watch ${signal.runId} and tell me when it starts running.` ); case "slow_run": { @@ -274,8 +274,8 @@ export function promptForSignal(signal: AgentPageSignal, now: number): Suggested case "concurrency_saturation": return ctx( "concurrency-saturation", - "Why is the backlog building up?", - "Concurrency is saturated right now. What's holding it up, and how big is the backlog?" + "Tell me when the backlog drains", + "Concurrency is saturated right now. Watch it and tell me when the backlog drains." ); } } @@ -303,7 +303,7 @@ export function contextualPromptsBySlot( ): Record { const bySlot: Record = { investigate: [], - status: [], + watch: [], explain: [], docs: [], }; diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts index ea1894c1092..3406664e04c 100644 --- a/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts +++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.test.ts @@ -22,7 +22,7 @@ const docsId = (key: keyof typeof demoPageContexts) => describe("resolveSuggestedPrompts", () => { it("fills all five slots when the page has a promoted chip, signals and defaults", () => { - // Error page: investigate + status + explain + docs defaults, plus a fresh + // Error page: investigate + watch + explain + docs defaults, plus a fresh // failure signal that takes the investigate slot. const prompts = resolveSuggestedPrompts(demoPageContexts.error, { promoted, now: NOW }); @@ -30,7 +30,7 @@ describe("resolveSuggestedPrompts", () => { expect(ids(prompts)).toEqual([ promoted.id, "sp:fresh-failure", - "sp:error-recent-occurrences", + "sp:error-watch-recurrence", "sp:error-similar", docsId("error"), ]); @@ -43,14 +43,14 @@ describe("resolveSuggestedPrompts", () => { expect(prompts).toHaveLength(4); expect(ids(prompts)).toEqual([ "sp:fresh-failure", - "sp:error-recent-occurrences", + "sp:error-watch-recurrence", "sp:error-similar", docsId("error"), ]); }); - it("shows explain + docs only when no investigate or status applies", () => { - // A deployment page has no failure to dig into and nothing live to report. + it("shows explain + docs only when no investigate or watch applies", () => { + // A deployment page has no failure to dig into and nothing to watch. const prompts = resolveSuggestedPrompts(demoPageContexts.deployment, { now: NOW }); expect(ids(prompts)).toEqual(ids(pageDefaultPrompts(demoPageContexts.deployment.page))); @@ -78,8 +78,8 @@ describe("resolveSuggestedPrompts", () => { } }); - it("orders promoted, then investigate, then status, then explain", () => { - // Queue page: saturation fills status, the fresh failure fills investigate. + it("orders promoted, then investigate, then watch, then explain", () => { + // Queue page: saturation fills watch, the fresh failure fills investigate. const context = { ...demoPageContexts.queue, signals: [...demoPageContexts.queue.signals, demoFreshFailureSignal], @@ -90,7 +90,7 @@ describe("resolveSuggestedPrompts", () => { expect(ids(prompts)).toEqual([ promoted.id, "sp:fresh-failure", - // waiting_run beats concurrency_saturation for the status slot. + // waiting_run beats concurrency_saturation for the watch slot. "sp:waiting-run", "sp:queue-state", docsId("queue"), @@ -131,10 +131,10 @@ describe("resolveSuggestedPrompts", () => { const full = resolveSuggestedPrompts(demoPageContexts.error, { now: NOW }); const dismissed = resolveSuggestedPrompts(demoPageContexts.error, { now: NOW, - dismissedIds: ["sp:error-recent-occurrences"], + dismissedIds: ["sp:error-watch-recurrence"], }); - expect(ids(dismissed)).not.toContain("sp:error-recent-occurrences"); + expect(ids(dismissed)).not.toContain("sp:error-watch-recurrence"); expect(dismissed).toHaveLength(full.length - 1); // Docs is still last. expect(dismissed.at(-1)?.id).toBe(docsId("error")); @@ -174,7 +174,7 @@ describe("resolveSuggestedPrompts", () => { it("words the waiting-run and slow-run chips for their slots", () => { const waiting = resolveSuggestedPrompts(demoPageContexts.waitingRun, { now: NOW }); const waitingChip = waiting.find((p) => p.id === "sp:waiting-run"); - expect(waitingChip?.label).toBe("Why hasn't this run started?"); + expect(waitingChip?.label).toBe("Tell me when this run starts"); expect(waitingChip?.prompt).toContain("queue"); const slow = resolveSuggestedPrompts(demoPageContexts.slowRun, { now: NOW }); diff --git a/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts index 1538c223cfc..012cf64bc0e 100644 --- a/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts +++ b/apps/webapp/app/components/dashboard-agent/suggested-prompts/resolver.ts @@ -5,8 +5,8 @@ * * 1. `promoted` — the product-chosen chip, when one is configured. * 2. `investigate` — when the page or its signals make one relevant. - * 3. `status` — when the page has something live worth asking about (a waiting - * run, a saturated queue, a recurring error). + * 3. `watch` — when there's something worth watching (a waiting run, a saturated + * queue, a recurring error). * 4. `explain` — the evergreen explain/find/show question. Always present. * 5. `docs` — a doc-flavored question. Always present, always last. * diff --git a/apps/webapp/app/components/dashboard-agent/tool-labels.ts b/apps/webapp/app/components/dashboard-agent/tool-labels.ts index b336fd028c1..9a8c319e227 100644 --- a/apps/webapp/app/components/dashboard-agent/tool-labels.ts +++ b/apps/webapp/app/components/dashboard-agent/tool-labels.ts @@ -31,6 +31,10 @@ const TOOL_LABELS: Record = { search_docs: "Searching the docs", get_current_page: "Reading the current page", navigate_to: "Opening the page", + schedule_watch: "Setting up a watch", + list_alerts: "Listing alerts", + create_alert: "Creating an alert", + delete_alert: "Deleting an alert", // Code mode. get_repo_info: "Reading the repo", list_files: "Listing files", diff --git a/apps/webapp/app/components/dashboard-agent/view-catalog.tsx b/apps/webapp/app/components/dashboard-agent/view-catalog.tsx index 93c76632058..b2d9da89313 100644 --- a/apps/webapp/app/components/dashboard-agent/view-catalog.tsx +++ b/apps/webapp/app/components/dashboard-agent/view-catalog.tsx @@ -4,6 +4,7 @@ import { InvestigationCard } from "./InvestigationCard"; import { ReportView, type ResolvedUri } from "./ReportView"; import { RunDiagnosisCard } from "./RunDiagnosisCard"; import { blockKey, latestRevisionBlocks } from "./view-blocks"; +import { WatchResultBlock } from "./WatchResultBlock"; // The render registry for the dashboard agent's view catalog — our small // "generative UI" layer. The agent emits a `render_view` tool call whose output @@ -67,6 +68,10 @@ export function ViewBlocks({ onIntent={onIntent} /> ); + // Host-emitted only (the watch card's submit path) — the model has no + // way to produce one, so a confirmation can never be fabricated. + case "watch_result": + return ; case "report": return ( { + // §7.5 binding: the transport keeps its two-value suffix, so persisted wakes + // and banner render keys stay valid under the resolution model. + it("still reads the as-built two-value wake id", () => { + expect(wakeRefFromMessageId("wake:watch:watch_1:fired")).toEqual({ + watchId: "watch_1", + outcome: "fired", + }); + expect(wakeRefFromMessageId("wake:watch:watch_1:expired")).toEqual({ + watchId: "watch_1", + outcome: "expired", + }); + expect(wakeRefFromMessageId("msg_1")).toBeNull(); + }); +}); + +describe("wakeResolution", () => { + it("prefers the row's resolution", () => { + expect(wakeResolution("expired", { resolution: "condition_impossible" })).toBe( + "condition_impossible" + ); + }); + + it("reconstructs one for a row written before the resolution column", () => { + expect(wakeResolution("fired", { endedReason: null })).toBe("condition_met"); + expect(wakeResolution("expired", { endedReason: "terminal_unsatisfied" })).toBe( + "condition_impossible" + ); + expect(wakeResolution("expired", { endedReason: "not_met_by_expiry" })).toBe( + "window_completed" + ); + expect(wakeResolution("expired", undefined)).toBe("window_completed"); + }); +}); + +describe("wakePresentation", () => { + it("states the fact, not a generic watch update", () => { + const presented = wakePresentation("fired", { + ...runWatch, + resolution: "condition_met", + observedOutcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_SUCCESSFULLY", + durationMs: 4200, + }, + }); + expect(presented.headline).toBe("Run run_abc123 finished"); + expect(presented.label).toBe("Watch update"); + expect(presented.category).toBe("positive"); + }); + + // The whole reason the resolution alone is insufficient (§4.2). + it("shows a failed run as a failure, on the same resolution", () => { + const presented = wakePresentation("fired", { + ...runWatch, + resolution: "condition_met", + observedOutcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: null, + }, + }); + expect(presented.headline).toBe("Run run_abc123 failed"); + expect(presented.category).toBe("attention"); + // Binding: a failed run never wears a success check. + expect(presented.semanticIcon).not.toBe("success"); + }); + + it("names the queue in a drain headline", () => { + expect( + wakePresentation("fired", { + id: "watch_2", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + note: "", + resolution: "condition_met", + }).headline + ).toBe("email-sends queue drained"); + }); + + it("reports the threshold watch with its number", () => { + expect( + wakePresentation("fired", { + id: "watch_3", + kind: "queue_depth_above", + identity: "queue_depth_above:email-sends:500", + note: "", + resolution: "condition_met", + observedOutcome: { + kind: "queue_depth_above", + verified: true, + depth: 612, + threshold: 500, + }, + }).headline + ).toBe("email-sends queue is still above 500"); + }); + + it("treats a completed window as an answer, not silence", () => { + const presented = wakePresentation("expired", { + id: "watch_4", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + note: "", + resolution: "window_completed", + observedOutcome: { kind: "backlog_drain", verified: true, depth: 42 }, + }); + expect(presented.headline).toBe("email-sends queue is still at 42"); + expect(presented.category).toBe("attention"); + }); + + it("says the condition couldn't be confirmed when the final read failed", () => { + expect( + wakePresentation("expired", { + id: "watch_5", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + note: "", + resolution: "window_completed", + observedOutcome: { kind: "backlog_drain", verified: false, depth: null }, + }).headline + ).toBe("The watch ended without a confirmed answer"); + }); + + it("falls back without guessing an outcome when the watch is gone", () => { + const presented = wakePresentation("fired", undefined); + expect(presented.headline).toBe("The watch woke this chat up on its own."); + expect(presented.category).toBe("neutral"); + }); + + it("says an error recurred, and that a quiet window was good news", () => { + const error = { + id: "watch_6", + kind: "error_recurrence", + identity: "error_recurrence:a1b2c3d4e5f6", + note: "", + }; + expect(wakePresentation("fired", { ...error, resolution: "condition_met" })).toMatchObject({ + headline: "Error a1b2c3d4 happened again", + category: "attention", + }); + expect(wakePresentation("expired", { ...error, resolution: "window_completed" })).toMatchObject( + { headline: "Error a1b2c3d4 stayed quiet", category: "positive" } + ); + }); + + it("recovers health without naming an identity", () => { + expect( + wakePresentation("fired", { + id: "watch_7", + kind: "health_recovery", + identity: "health_recovery:health", + note: "", + resolution: "condition_met", + }).headline + ).toBe("Health recovered"); + }); +}); + +// The toast is the out-of-panel copy of the same fact. It must never fall back +// to "Watch update" while the row can say what happened — the banner, the toast +// and the email all read one presenter (§5.2). +describe("watchWakeToastTitle", () => { + const wake = { + watchId: "watch_1", + chatId: "chat_1", + note: "tell me when the nightly invoice run finishes", + }; + + it("leads with the fact, not the notification", () => { + expect( + watchWakeToastTitle({ + ...wake, + outcome: "fired", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + resolution: "condition_met", + }) + ).toBe("email-sends queue drained"); + }); + + it("follows the observed outcome, so a failed run is never good news", () => { + expect( + watchWakeToastTitle({ + ...wake, + outcome: "fired", + kind: "run_finished", + identity: "run_finished:run_abc123", + resolution: "condition_met", + observedOutcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 1200, + }, + }) + ).toBe("Run run_abc123 failed"); + }); + + it("reconstructs a resolution for a row written before the model existed", () => { + expect( + watchWakeToastTitle({ + ...wake, + outcome: "expired", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + }) + ).toBe("email-sends queue still hasn't drained"); + }); + + it("claims nothing when the wake carries no watch at all", () => { + expect(watchWakeToastTitle({ ...wake, outcome: "fired" })).toBe( + "The watch woke this chat up on its own." + ); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/watch-card.test.ts b/apps/webapp/app/components/dashboard-agent/watch-card.test.ts new file mode 100644 index 00000000000..3c348f63193 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/watch-card.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; +import { + WATCH_MAX_QUEUE_THRESHOLD, + watchSpecSchema, + type WatchSpec, +} from "@internal/dashboard-agent-contracts"; +import { + clampCadence, + variantOf, + watchDraftError, + watchDraftFor, + withCadence, + withFollowUp, + withThreshold, + withVariant, + withWindow, +} from "./watch-card"; +import { + watchConditionLabel, + watchConfirmationBlockBody, + watchDurationLabel, + watchOneShotBlockBody, + watchSubjectLabel, +} from "./watch-presentation"; +import { + errorWatchRecommendation, + healthWatchRecommendation, + queueWatchRecommendation, + runWatchRecommendation, +} from "./watch-recommendations"; + +const queueDraft = () => watchDraftFor(queueWatchRecommendation("email-sends")); +const runDraft = () => watchDraftFor(runWatchRecommendation("run_abc123")); + +describe("the recommendations", () => { + it("gives every entry point a spec the schema accepts", () => { + const specs: WatchSpec[] = [ + runWatchRecommendation("run_abc123"), + queueWatchRecommendation("email-sends"), + errorWatchRecommendation("error_a1b2c3d4"), + healthWatchRecommendation("crit"), + ]; + for (const spec of specs) { + expect(watchSpecSchema.safeParse(spec).success).toBe(true); + } + }); + + it("recommends the condition §2.1 assigns to each object", () => { + expect(runWatchRecommendation("run_abc123").kind).toBe("run_finished"); + expect(queueWatchRecommendation("email-sends").kind).toBe("backlog_drain"); + expect(errorWatchRecommendation("error_a1b2c3d4").kind).toBe("error_recurrence"); + expect(healthWatchRecommendation("warn").kind).toBe("health_recovery"); + }); + + it("starts both follow-ups off — consent is never assumed", () => { + expect(runDraft().followUp).toEqual({ + investigateOnAttention: false, + notifyExternally: false, + }); + }); +}); + +describe("cadence limits", () => { + it("lets a run watch poll every minute", () => { + expect(clampCadence("run_finished", 1)).toBe(1); + }); + + it("floors an aggregate watch at five minutes — never a hot loop", () => { + expect(clampCadence("backlog_drain", 1)).toBe(5); + expect(clampCadence("queue_depth_above", 1)).toBe(5); + expect(clampCadence("health_recovery", 1)).toBe(5); + }); + + it("keeps an offered cadence and rounds an unknown one up", () => { + expect(clampCadence("backlog_drain", 15)).toBe(15); + expect(clampCadence("backlog_drain", 7)).toBe(15); + expect(clampCadence("backlog_drain", 999)).toBe(60); + }); + + it("re-clamps when the kind changes under the user", () => { + // A 1-minute run watch switched to the queue variant must LAND on 5, not + // carry a cadence the aggregate schema would then reject. + const swapped = withVariant(withCadence(runDraft(), 1), "backlog_drain"); + expect(swapped.spec.checkEveryMinutes).toBe(5); + expect(watchSpecSchema.safeParse(swapped.spec).success).toBe(true); + }); +}); + +describe("condition variants (§3)", () => { + it("pairs the two run questions and the two queue questions", () => { + expect(variantOf(runDraft())).toBe("run_failed"); + expect(variantOf(queueDraft())).toBe("queue_depth_above"); + expect(variantOf(watchDraftFor(errorWatchRecommendation("error_a1")))).toBeNull(); + expect(variantOf(watchDraftFor(healthWatchRecommendation("warn")))).toBeNull(); + }); + + it("carries the subject, window and note across a swap", () => { + const draft = withWindow(runDraft(), 6); + const failed = withVariant(draft, "run_failed"); + expect(failed.spec).toMatchObject({ + kind: "run_failed", + runId: "run_abc123", + maxHours: 6, + note: draft.spec.note, + }); + }); + + it("gives the threshold variant a usable default", () => { + const above = withVariant(queueDraft(), "queue_depth_above"); + expect(above.spec).toMatchObject({ kind: "queue_depth_above", queue: "email-sends" }); + expect(watchDraftError(above)).toBeNull(); + }); + + it("swaps back without losing the queue", () => { + const roundTrip = withVariant(withVariant(queueDraft(), "queue_depth_above"), "backlog_drain"); + expect(roundTrip.spec).toMatchObject({ kind: "backlog_drain", queue: "email-sends" }); + }); +}); + +describe("the window", () => { + it("never leaves the 24-hour ceiling", () => { + expect(withWindow(runDraft(), 999).spec.maxHours).toBe(24); + }); + + it("never goes below the shortest offered window", () => { + expect(withWindow(runDraft(), 0).spec.maxHours).toBe(0.5); + }); +}); + +describe("the follow-up opt-ins (§2.2, binding)", () => { + it("sets them INDEPENDENTLY — never as a radio group", () => { + const both = withFollowUp(withFollowUp(runDraft(), { notifyExternally: true }), { + investigateOnAttention: true, + }); + expect(both.followUp).toEqual({ investigateOnAttention: true, notifyExternally: true }); + }); + + it("turning one off leaves the other alone", () => { + const draft = withFollowUp(runDraft(), { + investigateOnAttention: true, + notifyExternally: true, + }); + expect(withFollowUp(draft, { notifyExternally: false }).followUp).toEqual({ + investigateOnAttention: true, + notifyExternally: false, + }); + }); + + it("has no way to express in-chat delivery at all — it is not a choice", () => { + expect(Object.keys(runDraft().followUp).sort()).toEqual([ + "investigateOnAttention", + "notifyExternally", + ]); + }); +}); + +describe("validation stays inside the card", () => { + it("accepts every recommendation as it opens", () => { + expect(watchDraftError(runDraft())).toBeNull(); + expect(watchDraftError(queueDraft())).toBeNull(); + }); + + it("refuses a half-typed threshold", () => { + const draft = withThreshold(withVariant(queueDraft(), "queue_depth_above"), Number.NaN); + expect(watchDraftError(draft)).toMatch(/whole number/i); + }); + + it("refuses a threshold above the queue-watch ceiling", () => { + const draft = withThreshold( + withVariant(queueDraft(), "queue_depth_above"), + WATCH_MAX_QUEUE_THRESHOLD + 1 + ); + expect(watchDraftError(draft)).toMatch(/too high/i); + }); + + it("ignores a threshold set on a kind that has none", () => { + expect(withThreshold(runDraft(), 5)).toEqual(runDraft()); + }); +}); + +describe("the card's copy", () => { + it("names the subject the way the object does", () => { + expect(watchSubjectLabel(queueWatchRecommendation("email-sends"))).toBe("email-sends"); + expect(watchSubjectLabel(runWatchRecommendation("run_abc123"))).toBe("run run_abc123"); + expect(watchSubjectLabel(healthWatchRecommendation("warn"))).toBe("health"); + }); + + it("states the condition and the duration as §2.2 writes them", () => { + const spec = queueWatchRecommendation("email-sends"); + expect(watchConditionLabel(spec)).toBe("Until the queue drains"); + expect(watchDurationLabel(spec)).toBe("For 1 hour · checking every 5 min"); + }); + + it("carries the threshold into the condition line", () => { + const above = withThreshold(withVariant(queueDraft(), "queue_depth_above"), 500); + expect(watchConditionLabel(above.spec)).toBe("If the queue goes above 500"); + }); +}); + +describe("the persisted blocks (§2.2)", () => { + it("states all four lifetime facts on a confirmation", () => { + const body = watchConfirmationBlockBody({ + spec: queueWatchRecommendation("email-sends"), + watchId: "watch_1", + }); + expect(body.outcome).toBe("watching"); + // what · how often it checks · that it reports once · when it gives up + expect(body.headline).toBe("Watching email-sends until the queue drains."); + expect(body.lifetime).toBe( + "Checking every 5 min for up to 1 hour. It reports once, then stops." + ); + expect(body.watchId).toBe("watch_1"); + expect(body.detail).toBeNull(); + }); + + it("says plainly when the creation-time check couldn't run", () => { + const body = watchConfirmationBlockBody({ + spec: queueWatchRecommendation("email-sends"), + watchId: "watch_1", + unavailable: true, + }); + expect(body.detail).toBe("We couldn't check that just now. Watching anyway."); + }); + + it("only claims a follow-up that actually took effect", () => { + const body = watchConfirmationBlockBody({ + spec: queueWatchRecommendation("email-sends"), + watchId: "watch_1", + followUp: { investigateOnAttention: true, notifyExternally: false }, + }); + expect(body.followUp).toEqual(["If it turns out badly, I'll investigate straight away."]); + }); + + it("makes a one-shot result carry no lifetime and no watch", () => { + const satisfied = watchOneShotBlockBody({ + spec: queueWatchRecommendation("email-sends"), + result: "satisfied", + }); + expect(satisfied.outcome).toBe("already_true"); + expect(satisfied.headline).toBe("That already happened, so there's nothing left to watch."); + expect(satisfied.lifetime).toBeNull(); + expect(satisfied.watchId).toBeNull(); + + const impossible = watchOneShotBlockBody({ + spec: runWatchRecommendation("run_abc123"), + result: "terminal_unsatisfied", + }); + expect(impossible.outcome).toBe("impossible"); + expect(impossible.headline).toBe("That can't happen any more, so there's nothing to watch."); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/watch-card.ts b/apps/webapp/app/components/dashboard-agent/watch-card.ts new file mode 100644 index 00000000000..b83229ca188 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/watch-card.ts @@ -0,0 +1,135 @@ +/** + * The watch card's state machine — pure, so every rule the card enforces is + * testable without a DOM. + * + * The card never invents a value the schema would then reject: switching to a + * condition variant re-clamps the cadence (an aggregate kind is floored at 5 + * minutes), and the window is always one of the offered options. That is why the + * option lists live in contracts (`watchCadenceOptions`, `WATCH_WINDOW_HOURS_OPTIONS`) + * and are read from here rather than re-typed: a picker can't offer something + * validation would refuse. + * + * Nothing here persists anything. A draft is client-side until `Start watching` + * submits it (§2.2, transcript hygiene). + */ +import { + WATCH_DEFAULT_QUEUE_THRESHOLD, + WATCH_MAX_HOURS, + WATCH_MAX_QUEUE_THRESHOLD, + WATCH_WINDOW_HOURS_OPTIONS, + watchCadenceOptions, + watchSpecSchema, + watchVariantKind, + type WatchDraft, + type WatchFollowUp, + type WatchKind, + type WatchSpec, +} from "@internal/dashboard-agent-contracts"; + +/** A brand-new draft: the recommendation, with both opt-ins off (§6). */ +export function watchDraftFor(spec: WatchSpec): WatchDraft { + return { spec, followUp: { investigateOnAttention: false, notifyExternally: false } }; +} + +/** + * The nearest cadence this kind is allowed to poll at. Used whenever the kind + * changes under the user: a 1-minute run watch switched to a queue variant must + * land on 5, not fail validation on submit. + */ +export function clampCadence(kind: WatchKind, minutes: number): number { + const options = watchCadenceOptions(kind); + if (options.includes(minutes)) return minutes; + return options.find((option) => option >= minutes) ?? options[options.length - 1]!; +} + +/** Swap the condition for its sibling variant (§3), carrying everything else. */ +export function withVariant(draft: WatchDraft, kind: WatchKind): WatchDraft { + const { spec } = draft; + const common = { + note: spec.note, + maxHours: spec.maxHours, + checkEveryMinutes: clampCadence(kind, spec.checkEveryMinutes), + } as const; + + switch (kind) { + case "run_finished": + case "run_failed": + case "run_start": { + const runId = "runId" in spec ? spec.runId : ""; + return { ...draft, spec: { ...common, kind, runId } as WatchSpec }; + } + case "backlog_drain": { + const queue = "queue" in spec ? spec.queue : ""; + return { ...draft, spec: { ...common, kind, queue } as WatchSpec }; + } + case "queue_depth_above": { + const queue = "queue" in spec ? spec.queue : ""; + const threshold = "threshold" in spec ? spec.threshold : WATCH_DEFAULT_QUEUE_THRESHOLD; + return { ...draft, spec: { ...common, kind, queue, threshold } as WatchSpec }; + } + // The kinds with no second question keep the draft untouched. + default: + return draft; + } +} + +/** The sibling this draft can toggle to, or null when the kind has none. */ +export function variantOf(draft: WatchDraft): WatchKind | null { + return watchVariantKind(draft.spec.kind); +} + +export function withCadence(draft: WatchDraft, minutes: number): WatchDraft { + return { + ...draft, + spec: { + ...draft.spec, + checkEveryMinutes: clampCadence(draft.spec.kind, minutes), + } as WatchSpec, + }; +} + +export function withWindow(draft: WatchDraft, maxHours: number): WatchDraft { + const clamped = Math.min(Math.max(maxHours, WATCH_WINDOW_HOURS_OPTIONS[0]), WATCH_MAX_HOURS); + return { ...draft, spec: { ...draft.spec, maxHours: clamped } as WatchSpec }; +} + +/** + * The threshold, as the user is typing it. Kept out of range checks on purpose — + * an empty or half-typed field is not an error yet, it is a draft; `watchDraftError` + * is what refuses to submit one. + */ +export function withThreshold(draft: WatchDraft, threshold: number): WatchDraft { + if (draft.spec.kind !== "queue_depth_above") return draft; + return { ...draft, spec: { ...draft.spec, threshold } }; +} + +/** + * The two follow-up opt-ins, set INDEPENDENTLY (§2.2, binding). There is + * deliberately no way to express "external instead of chat": in-chat delivery is + * not in this shape at all, because it is not a choice. + */ +export function withFollowUp(draft: WatchDraft, patch: Partial): WatchDraft { + return { ...draft, followUp: { ...draft.followUp, ...patch } }; +} + +/** + * Why this draft can't be submitted, in the user's words — or null when it can. + * + * The schema is the authority (so the card and the server agree by construction); + * this only translates its refusal into the one sentence the card shows inline. + */ +export function watchDraftError(draft: WatchDraft): string | null { + if (draft.spec.kind === "queue_depth_above") { + const { threshold } = draft.spec; + if (!Number.isInteger(threshold) || threshold < 0) { + return "Enter a whole number to watch for."; + } + if (threshold > WATCH_MAX_QUEUE_THRESHOLD) { + return `That threshold is too high — ${WATCH_MAX_QUEUE_THRESHOLD.toLocaleString()} is the most a queue watch takes.`; + } + } + + return watchSpecSchema.safeParse(draft.spec).success + ? null + : "Something in this watch isn't valid. Check the duration and the condition."; +} diff --git a/apps/webapp/app/components/dashboard-agent/watch-chips.test.ts b/apps/webapp/app/components/dashboard-agent/watch-chips.test.ts new file mode 100644 index 00000000000..4734d5966db --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/watch-chips.test.ts @@ -0,0 +1,107 @@ +import { watchIdentity, type WatchSpec } from "@internal/dashboard-agent-contracts"; +import { describe, expect, it } from "vitest"; +import { immediateWatchMessage, watchChipLabel, watchChipTooltip } from "./watch-chips"; + +const chip = (spec: WatchSpec) => ({ + kind: spec.kind, + identity: watchIdentity(spec), + note: spec.note, +}); + +describe("watchChipLabel", () => { + it("labels a run watch with its run id", () => { + expect( + watchChipLabel( + chip({ + kind: "run_finished", + runId: "run_abc123", + note: "Tell me when the retry finishes.", + maxHours: 2, + checkEveryMinutes: 1, + }) + ) + ).toBe("run_abc123"); + }); + + it("labels a backlog watch with the queue name", () => { + expect( + watchChipLabel( + chip({ + kind: "backlog_drain", + queue: "task/send-email", + note: "Tell me when the backlog clears.", + maxHours: 6, + checkEveryMinutes: 5, + }) + ) + ).toBe("task/send-email"); + }); + + it("shortens an error fingerprint", () => { + expect( + watchChipLabel( + chip({ + kind: "error_recurrence", + fingerprint: "0123456789abcdef0123456789abcdef", + note: "Tell me if the rate-limit error comes back.", + maxHours: 12, + checkEveryMinutes: 15, + }) + ) + ).toBe("01234567"); + }); + + it("labels a health watch by its kind, not its report", () => { + expect( + watchChipLabel( + chip({ + kind: "health_recovery", + report: "health", + fromSeverity: "crit", + note: "prod health back to normal", + maxHours: 4, + checkEveryMinutes: 15, + }) + ) + ).toBe("health"); + }); + + it("falls back to the first words of the note when the identity is unreadable", () => { + expect( + watchChipLabel({ kind: "run_start", identity: "nonsense", note: "Tell me when it starts" }) + ).toBe("Tell me when"); + }); + + it("falls back to the kind when there is no note either", () => { + expect(watchChipLabel({ kind: "run_start", identity: "", note: " " })).toBe("run_start"); + }); +}); + +describe("watchChipTooltip", () => { + it("carries the note, the cadence and the state", () => { + expect( + watchChipTooltip({ + note: "Tell me when prod recovers.", + checkEveryMinutes: 15, + status: "active", + }) + ).toBe("Tell me when prod recovers. · every 15 min · watching"); + }); + + it("drops an empty note rather than leaving a dangling separator", () => { + expect(watchChipTooltip({ note: "", checkEveryMinutes: 5, status: "fired" })).toBe( + "every 5 min · fired" + ); + }); +}); + +describe("immediateWatchMessage", () => { + it("says the condition already resolved", () => { + expect(immediateWatchMessage("satisfied")).toMatch(/already happened/); + expect(immediateWatchMessage("terminal_unsatisfied")).toMatch(/can't happen any more/); + }); + + it("never falls through to nothing", () => { + expect(immediateWatchMessage("something-new")).toBeTruthy(); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/watch-chips.ts b/apps/webapp/app/components/dashboard-agent/watch-chips.ts new file mode 100644 index 00000000000..fe27fac7f54 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/watch-chips.ts @@ -0,0 +1,73 @@ +/** + * The pure text of the watch UI: what a chip is labelled, what its tooltip says, + * and how an immediate outcome is worded. + * + * A chip has one line of room in a 380px panel, so the label names the *thing* + * being watched (the run, the queue, the error) and the icon carries the state. + * The label comes from the watch `identity` — the same dedup key the store uses — + * so two chips can never disagree with the store about what they watch. + */ +import type { WatchStatus } from "@internal/dashboard-agent-contracts"; + +// The immediate-check outcomes moved to the presenter: they are user-facing +// wording, and §5.2 keeps all of that in one place. Re-exported so chip callers +// don't have to know it moved. +export { immediateWatchMessage } from "./watch-presentation"; + +import { formatWatchCadence, watchIdentityValue } from "./watch-presentation"; + +export const WATCH_STATUS_LABEL: Record = { + active: "watching", + fired: "fired", + expired: "expired", + cancelled: "cancelled", +}; + +/** Fingerprints are hashes — a chip shows just enough of one to tell them apart. */ +const FINGERPRINT_CHARS = 8; + +/** + * The chip label for a watch. `identity` is `{kind}:{value}`, so the value is the + * thing being watched; a health watch has no per-instance value, so its kind is + * the label. Falls back to the note (then the kind) if the identity is unreadable. + */ +export function watchChipLabel(watch: { kind: string; identity: string; note: string }): string { + const value = watch.identity.startsWith(`${watch.kind}:`) + ? watch.identity.slice(watch.kind.length + 1) + : ""; + + switch (watch.kind) { + case "run_start": + case "run_finished": + case "run_failed": + case "backlog_drain": + return value || fallbackLabel(watch); + // `queue_depth_above:{queue}:{threshold}` — the chip names the queue; the + // threshold is in the tooltip's note, where there is room for it. + case "queue_depth_above": + return watchIdentityValue(watch.kind, watch.identity) || fallbackLabel(watch); + case "error_recurrence": + return value ? value.slice(0, FINGERPRINT_CHARS) : fallbackLabel(watch); + case "health_recovery": + return "health"; + default: + return value || fallbackLabel(watch); + } +} + +/** Last resort: the first few words of the note, else the kind as written. */ +function fallbackLabel(watch: { kind: string; note: string }): string { + const words = watch.note.trim().split(/\s+/).filter(Boolean).slice(0, 3).join(" "); + return words || watch.kind; +} + +/** Everything that didn't fit on the chip: why it exists, and its cadence. */ +export function watchChipTooltip(watch: { + note: string; + checkEveryMinutes: number; + status: WatchStatus; +}): string { + const note = watch.note.trim(); + const cadence = formatWatchCadence(watch.checkEveryMinutes); + return [note, cadence, WATCH_STATUS_LABEL[watch.status]].filter(Boolean).join(" · "); +} diff --git a/apps/webapp/app/components/dashboard-agent/watch-presentation.ts b/apps/webapp/app/components/dashboard-agent/watch-presentation.ts new file mode 100644 index 00000000000..1f5e3bb83dd --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/watch-presentation.ts @@ -0,0 +1,446 @@ +/** + * The words a resolved watch is said in. One module, all of them. + * + * Two layers, one meaning (§5.2). Contracts owns the **exhaustive resolved-result + * mapping** — resolution + observed outcome → presentation category, tone, + * semantic icon and headline *key*. This module owns the **final English**: the + * headline sentences, the immediate-check outcomes, and the identity / duration / + * value formatting that goes into them. + * + * It is pure and has no React in it, so the banner, the toast and the email + * template can all render its output. **Components contain no kind-specific + * wording of their own** — if a component is writing a sentence about a queue or + * a run, the sentence belongs here. + * + * Headlines are FACT FIRST (§5.3): "email-sends queue drained", not "Watch update + * — all clear". The `WATCH UPDATE` micro-label carries the "this is a wake" + * signal, so the headline itself is free to just say what happened. + */ +import { + isWatchKind, + resolveWatchResult, + type WatchHeadlineKey, + type WatchKind, + type WatchObservedOutcome, + type WatchResolution, + type WatchResolvedPresentation, + type WatchSemanticIcon, + type WatchSpec, +} from "@internal/dashboard-agent-contracts"; + +/** The micro-label above a wake headline. Not part of the fact. */ +export const WATCH_UPDATE_LABEL = "Watch update"; + +/** Fingerprints are hashes — show just enough of one to tell them apart. */ +const FINGERPRINT_CHARS = 8; + +/* ------------------------------------------------------------------ * + * Identity formatting + * ------------------------------------------------------------------ */ + +/** + * The value half of a watch `identity` (`{kind}:{value}`) — the run id, the queue + * name, the fingerprint. Taken from the identity rather than the spec because the + * identity is the store's own dedup key: a surface reading it can never disagree + * with the store about what is being watched. + * + * `queue_depth_above` appends its threshold to the identity, so only the first + * segment is the queue name. + */ +export function watchIdentityValue(kind: string, identity: string): string { + const value = identity.startsWith(`${kind}:`) ? identity.slice(kind.length + 1) : ""; + if (kind === "queue_depth_above") { + const lastColon = value.lastIndexOf(":"); + return lastColon > 0 ? value.slice(0, lastColon) : value; + } + return value; +} + +/** How a run is named in a sentence. */ +function runName(identity: string, kind: string): string { + const value = watchIdentityValue(kind, identity); + return value ? `Run ${value}` : "The run"; +} + +/** How a queue is named in a sentence: the name, then the word "queue". */ +function queueName(identity: string, kind: string): string { + const value = watchIdentityValue(kind, identity); + return value ? `${value} queue` : "The queue"; +} + +/** How an error group is named in a sentence. */ +function errorName(identity: string, kind: string): string { + const value = watchIdentityValue(kind, identity); + return value ? `Error ${value.slice(0, FINGERPRINT_CHARS)}` : "The error"; +} + +/* ------------------------------------------------------------------ * + * Value formatting + * ------------------------------------------------------------------ */ + +/** A duration in the shortest honest form. Never invents precision. */ +export function formatWatchDuration(ms: number | null | undefined): string | null { + if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) return null; + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +/** A window length, as the confirmation and the tooltip state it. */ +export function formatWatchWindow(maxHours: number): string { + if (maxHours < 1) return `${Math.round(maxHours * 60)} min`; + return maxHours === 1 ? "1 hour" : `${maxHours} hours`; +} + +/** A cadence, as the confirmation and the tooltip state it. */ +export function formatWatchCadence(checkEveryMinutes: number): string { + return checkEveryMinutes === 60 ? "every hour" : `every ${checkEveryMinutes} min`; +} + +/* ------------------------------------------------------------------ * + * Headlines + * ------------------------------------------------------------------ */ + +export type WatchResolvedInput = { + kind: WatchKind | string; + /** The store's dedup key for the watched thing. */ + identity: string; + resolution: WatchResolution; + observed?: WatchObservedOutcome | null; +}; + +/** + * The final English for one headline key. Every kind-specific sentence in the + * product is in this switch and nowhere else. + * + * The strings state the FACT, and the depth/threshold numbers come from the + * frozen observation — never from a fresh read, so the sentence a retry produces + * is the sentence the first attempt produced (§7.5). + */ +function headlineFor(key: WatchHeadlineKey, input: WatchResolvedInput): string { + const { kind, identity, observed } = input; + + switch (key) { + case "run_started": + return `${runName(identity, kind)} started`; + case "run_not_started": + return `${runName(identity, kind)} hasn't started yet`; + case "run_never_starts": + return `${runName(identity, kind)} will never start`; + + case "run_finished": + return `${runName(identity, kind)} finished`; + case "run_failed": + return `${runName(identity, kind)} failed`; + case "run_cancelled": + return `${runName(identity, kind)} was cancelled`; + case "run_still_running": + return `${runName(identity, kind)} is still running`; + case "run_gone": + return `${runName(identity, kind)} is no longer there`; + + case "run_no_failure": + return `${runName(identity, kind)} hasn't failed`; + case "run_succeeded": + return `${runName(identity, kind)} succeeded`; + + case "queue_drained": + return `${queueName(identity, kind)} drained`; + case "queue_not_drained": { + // The observed depth makes the fact concrete. Without it, stay vague + // rather than invent a number. + const depth = observed?.kind === "backlog_drain" ? observed.depth : null; + return depth === null + ? `${queueName(identity, kind)} still hasn't drained` + : `${queueName(identity, kind)} is still at ${depth}`; + } + case "queue_gone": + return `${queueName(identity, kind)} no longer exists`; + + case "queue_above_threshold": { + const threshold = observed?.kind === "queue_depth_above" ? observed.threshold : null; + return threshold === null + ? `${queueName(identity, kind)} is above the threshold` + : `${queueName(identity, kind)} is still above ${threshold}`; + } + case "queue_stayed_below": { + const threshold = observed?.kind === "queue_depth_above" ? observed.threshold : null; + return threshold === null + ? `${queueName(identity, kind)} stayed below the threshold` + : `${queueName(identity, kind)} stayed below ${threshold}`; + } + + case "error_recurred": + return `${errorName(identity, kind)} happened again`; + case "error_quiet": + return `${errorName(identity, kind)} stayed quiet`; + + case "health_recovered": + return "Health recovered"; + case "health_not_recovered": + return "Health hasn't recovered"; + case "health_unavailable": + return "Health couldn't be read"; + + // Honest by design: the window ran out while the source was unreadable, so + // this says nothing about the condition itself. + case "unverified_at_window_end": + return "The watch ended without a confirmed answer"; + + default: { + const unreachable: never = key; + throw new Error(`Unhandled watch headline key: ${JSON.stringify(unreachable)}`); + } + } +} + +/** Everything a surface needs to render one resolved watch. */ +export type WatchPresentation = WatchResolvedPresentation & { + /** The fact, in final English. Complete without any narration under it. */ + headline: string; + /** The micro-label that marks this as an unprompted wake. */ + label: string; +}; + +/** + * Present one resolved watch. The single entry point for the banner, the toast + * and the email — they render this and add nothing of their own. + */ +export function presentResolvedWatch(input: WatchResolvedInput): WatchPresentation { + // A kind the store knows and this build doesn't must not crash a banner or + // silence an email — it degrades to the neutral fallback, which claims + // nothing about the outcome. + if (!isWatchKind(input.kind)) return WATCH_PRESENTATION_FALLBACK; + + const resolved = resolveWatchResult({ + kind: input.kind, + resolution: input.resolution, + outcome: input.observed ?? null, + }); + return { + ...resolved, + headline: headlineFor(resolved.headlineKey, { ...input, kind: input.kind }), + label: WATCH_UPDATE_LABEL, + }; +} + +/** + * The same presentation for a watch whose row this surface couldn't load — the + * banner's fallback (§5.2). It never guesses an outcome. + */ +export const WATCH_PRESENTATION_FALLBACK: WatchPresentation = { + category: "neutral", + tone: "neutral", + semanticIcon: "info", + headlineKey: "unverified_at_window_end", + headline: "The watch woke this chat up on its own.", + label: WATCH_UPDATE_LABEL, +}; + +/* ------------------------------------------------------------------ * + * The one-shot result block (§2.2 / §4.1) + * ------------------------------------------------------------------ */ + +/** + * What the immediate check answered with, when it answered outright. No watch + * exists in either case: the check IS the delivery, and there will be no chip and + * no wake. + */ +export function immediateWatchMessage(result: string): string { + switch (result) { + case "satisfied": + return "That already happened, so there's nothing left to watch."; + case "terminal_unsatisfied": + return "That can't happen any more, so there's nothing to watch."; + // Not one-shot outcomes — the watch is created and running — but worded here + // so a confirmation can never fall through to nothing. + case "unavailable": + return "We couldn't check that just now. Watching anyway."; + default: + return "Watching."; + } +} + +/** + * The four lifetime facts a confirmation always states (§5.1.4): what · how often + * it checks · that it reports once · when it gives up. + */ +export function watchLifetimeSentence(args: { + checkEveryMinutes: number; + maxHours: number; +}): string { + return `Checking ${formatWatchCadence(args.checkEveryMinutes)} for up to ${formatWatchWindow( + args.maxHours + )}. It reports once, then stops.`; +} + +/** + * The icon a surface should draw, keyed by MEANING. Exported so the mapping from + * semantic icon to a concrete glyph lives in the component that owns the icon + * set, and the choice itself stays here. + */ +export type { WatchSemanticIcon }; + +/* ------------------------------------------------------------------ * + * The configuration card (§2.2) + * ------------------------------------------------------------------ */ + +/** Fixed, always on: the card states it as a fact, not as a choice (§2.2). */ +export const WATCH_IN_CHAT_DELIVERY_LINE = "When there's an answer: tell me in chat"; + +/** + * WHAT is being watched, as the card's title names it — from the SPEC, because + * the card exists before any watch row does. The `{kind}:{value}` identity + * formatting above is the same answer read off the store; this is the same answer + * read off the draft. + */ +export function watchSubjectLabel(spec: WatchSpec): string { + switch (spec.kind) { + case "run_start": + case "run_finished": + case "run_failed": + return `run ${spec.runId}`; + case "backlog_drain": + case "queue_depth_above": + return spec.queue; + case "error_recurrence": + return `error ${spec.fingerprint.slice(0, FINGERPRINT_CHARS)}`; + case "health_recovery": + return "health"; + } +} + +/** + * The condition line: "Until the queue drains", "If it fails". Written as the + * user would read it under the subject, so the two lines together are one + * sentence without repeating the subject. + */ +export function watchConditionLabel(spec: WatchSpec): string { + switch (spec.kind) { + case "run_start": + return "Until it starts"; + case "run_finished": + return "Until it finishes"; + case "run_failed": + return "If it fails"; + case "backlog_drain": + return "Until the queue drains"; + case "queue_depth_above": + return `If the queue goes above ${spec.threshold}`; + case "error_recurrence": + return "If it happens again"; + case "health_recovery": + return "Until it recovers"; + } +} + +/** "For 1 hour · checking every 5 min" — the duration line of the card. */ +export function watchDurationLabel(spec: WatchSpec): string { + return `For ${formatWatchWindow(spec.maxHours)} · checking ${formatWatchCadence( + spec.checkEveryMinutes + )}`; +} + +/** The condition as a clause that follows "Watching {subject} …". */ +function watchConditionClause(spec: WatchSpec): string { + switch (spec.kind) { + case "run_start": + return "until it starts"; + case "run_finished": + return "until it finishes"; + case "run_failed": + return "in case it fails"; + case "backlog_drain": + return "until the queue drains"; + case "queue_depth_above": + return `in case the queue goes above ${spec.threshold}`; + case "error_recurrence": + return "in case it happens again"; + case "health_recovery": + return "until it recovers"; + } +} + +/* ------------------------------------------------------------------ * + * The persisted blocks (§2.2) + * ------------------------------------------------------------------ */ + +/** The follow-up lines a confirmation states, for the opt-ins that took effect. */ +export function watchFollowUpLines(followUp: { + investigateOnAttention?: boolean; + notifyExternally?: boolean; +}): string[] { + const lines: string[] = []; + if (followUp.investigateOnAttention) { + lines.push("If it turns out badly, I'll investigate straight away."); + } + if (followUp.notifyExternally) lines.push("You'll get an email as well as the chat."); + return lines; +} + +/** + * The CONFIRMATION block: a watch is running. It states the four lifetime facts + * (§5.1.4) and nothing else — no separate request line, because this block is the + * transcript record of the request. + */ +export function watchConfirmationBlockBody(args: { + spec: WatchSpec; + watchId: string; + /** The creation-time check couldn't run. Said plainly rather than hidden. */ + unavailable?: boolean; + followUp?: { investigateOnAttention?: boolean; notifyExternally?: boolean }; +}): { + type: "watch_result"; + outcome: "watching"; + headline: string; + lifetime: string; + detail: string | null; + followUp: string[]; + watchId: string; +} { + return { + type: "watch_result", + outcome: "watching", + headline: `Watching ${watchSubjectLabel(args.spec)} ${watchConditionClause(args.spec)}.`, + lifetime: watchLifetimeSentence({ + checkEveryMinutes: args.spec.checkEveryMinutes, + maxHours: args.spec.maxHours, + }), + detail: args.unavailable ? immediateWatchMessage("unavailable") : null, + followUp: watchFollowUpLines(args.followUp ?? {}), + watchId: args.watchId, + }; +} + +/** + * The ONE-SHOT RESULT block: the immediate check answered outright, so no watch + * was created. No lifetime — there is nothing running to have one — and no + * follow-ups, because there is no later outcome to follow up on. + */ +export function watchOneShotBlockBody(args: { + spec: WatchSpec; + result: "satisfied" | "terminal_unsatisfied"; +}): { + type: "watch_result"; + outcome: "already_true" | "impossible"; + headline: string; + lifetime: null; + detail: null; + followUp: never[]; + watchId: null; +} { + const satisfied = args.result === "satisfied"; + return { + type: "watch_result", + outcome: satisfied ? "already_true" : "impossible", + headline: immediateWatchMessage(args.result), + lifetime: null, + detail: null, + followUp: [], + watchId: null, + }; +} diff --git a/apps/webapp/app/components/dashboard-agent/watch-recommendations.ts b/apps/webapp/app/components/dashboard-agent/watch-recommendations.ts new file mode 100644 index 00000000000..8e581e28b8f --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/watch-recommendations.ts @@ -0,0 +1,74 @@ +/** + * The condition each object recommends when its **Watch…** action opens (§2.1). + * + * Kept out of the routes and out of the button, exactly like `investigate-prompts.ts`: + * these defaults are product decisions (which condition, how long, how often), and + * they carry real identifiers, so they belong in one testable place. + * + * | Object | Recommendation | + * |---|---| + * | Run | when it finishes | + * | Queue | when it drains | + * | Error | if it happens again | + * | Health (degraded) | when it recovers | + * + * Every other variant is one tap deeper, under **Customize** — nothing here is a + * menu of options. + */ +import type { WatchSpec } from "@internal/dashboard-agent-contracts"; + +/** + * A run is the one object worth checking every minute: it is a single row read, + * and a run that lands in ninety seconds should not be reported five minutes late. + */ +export function runWatchRecommendation(runFriendlyId: string): WatchSpec { + return { + kind: "run_finished", + runId: runFriendlyId, + checkEveryMinutes: 1, + maxHours: 1, + note: `tell me when run ${runFriendlyId} finishes`, + }; +} + +/** A backlog is an aggregate, so the cadence starts at the 5-minute floor (§7.1). */ +export function queueWatchRecommendation(queueName: string): WatchSpec { + return { + kind: "backlog_drain", + queue: queueName, + checkEveryMinutes: 5, + maxHours: 1, + note: `tell me when the ${queueName} queue drains`, + }; +} + +/** + * A recurrence needs room to happen: an error that comes back in ten minutes was + * never really fixed, but plenty come back within the working day — hence the + * longer window than the other three. + */ +export function errorWatchRecommendation(errorFriendlyId: string): WatchSpec { + return { + kind: "error_recurrence", + fingerprint: errorFriendlyId, + checkEveryMinutes: 5, + maxHours: 6, + note: `ping me if error ${errorFriendlyId} happens again`, + }; +} + +/** + * Only offered on a DEGRADED report: "watch for a recovery" is meaningless while + * everything is fine, and `fromSeverity` is the state the recovery is measured + * from. + */ +export function healthWatchRecommendation(fromSeverity: "warn" | "crit"): WatchSpec { + return { + kind: "health_recovery", + report: "health", + fromSeverity, + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me when health is back to normal", + }; +} diff --git a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts index 83ab09c177c..dff916a6aa1 100644 --- a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts @@ -18,6 +18,7 @@ export const ApiAlertType = z.enum([ "deployment_failure", "deployment_success", "error_group", + "dashboard_agent_watch", ]); export type ApiAlertType = z.infer; @@ -88,6 +89,8 @@ export class ApiAlertChannelPresenter { return "deployment_success"; case "ERROR_GROUP": return "error_group"; + case "DASHBOARD_AGENT_WATCH": + return "dashboard_agent_watch"; default: assertNever(alertType); } @@ -105,6 +108,8 @@ export class ApiAlertChannelPresenter { return "DEPLOYMENT_SUCCESS"; case "error_group": return "ERROR_GROUP"; + case "dashboard_agent_watch": + return "DASHBOARD_AGENT_WATCH"; default: assertNever(alertType); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx index 563155468f4..a6a40e15bc0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx @@ -50,9 +50,13 @@ import { const FormSchema = z .object({ alertTypes: z - .array(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])) + .array( + z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS", "DASHBOARD_AGENT_WATCH"]) + ) .min(1) - .or(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])), + .or( + z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS", "DASHBOARD_AGENT_WATCH"]) + ), environmentTypes: z .array(z.enum(["STAGING", "PRODUCTION", "PREVIEW"])) .min(1) @@ -453,6 +457,18 @@ export default function Page() { defaultChecked /> +
+ + +
+ {alertTypes.errors} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx index 93828a1f0ff..99ed627d61f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx @@ -569,6 +569,8 @@ export function alertTypeTitle(alertType: ProjectAlertType): string { return "Deployment success"; case "ERROR_GROUP": return "Error group"; + case "DASHBOARD_AGENT_WATCH": + return "Dashboard agent watches"; default: { throw new Error(`Unknown alertType: ${alertType}`); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 0a40d1e7c15..9bf8807f371 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -24,6 +24,8 @@ import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { CodeBlock } from "~/components/code/CodeBlock"; import { InvestigateButton } from "~/components/dashboard-agent/InvestigateButton"; +import { WatchButton } from "~/components/dashboard-agent/WatchButton"; +import { errorWatchRecommendation } from "~/components/dashboard-agent/watch-recommendations"; import { errorGroupPrompt } from "~/components/dashboard-agent/investigate-prompts"; import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge"; import { @@ -583,13 +585,19 @@ function ErrorDetailSidebar({
Details {/* Hand this error group to the agent. Hidden when the agent isn't available. */} - +
+ + {/* Same entry, this object's recommendation: if it happens again. */} + +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 934165cfc50..43d40a9d7dd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -9,6 +9,8 @@ import { MetricsLayout } from "~/components/layout/MetricsLayout"; import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar"; import { BigNumber } from "~/components/metrics/BigNumber"; import { Header3 } from "~/components/primitives/Headers"; +import { WatchButton } from "~/components/dashboard-agent/WatchButton"; +import { queueWatchRecommendation } from "~/components/dashboard-agent/watch-recommendations"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Spinner } from "~/components/primitives/Spinner"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; @@ -302,6 +304,9 @@ export default function Page() { maxPeriodDays={maxPeriodDays} shortcut={{ key: "d" }} /> + {/* The universal `Watch…` entry, pre-filled with this queue's + recommendation: tell me when it drains. */} + ; + try { + const parsed = BodySchema.safeParse(await request.json()); + if (!parsed.success) { + return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 }); + } + body = parsed.data; + } catch { + return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 }); + } + + const context = await resolveAgentAlertContext({ + userId, + environmentId, + chatId: body.chatId, + claimedEnvironmentId: body.environmentId, + claimedProjectRef: body.projectRef, + }); + if (!context.ok) { + return json( + { error: context.error, code: context.code }, + { status: context.code === "environment_mismatch" ? 400 : 404 } + ); + } + + const result = await unsubscribeChannelFromWatchAlerts(parsedParams.data.channelId, { + projectId: context.environment.project.id, + }); + if (!result.ok) { + if (result.reason === "conflict") { + return json( + { error: "That alert was being changed elsewhere. Try again.", code: "conflict" }, + { status: 409 } + ); + } + return json({ error: "Alert not found", code: "not_found" }, { status: 404 }); + } + + return json({ ok: true, disabledChannel: result.disabledChannel }); +} diff --git a/apps/webapp/app/routes/api.v1.dashboard-agent.alerts.ts b/apps/webapp/app/routes/api.v1.dashboard-agent.alerts.ts new file mode 100644 index 00000000000..bb853eee982 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.dashboard-agent.alerts.ts @@ -0,0 +1,227 @@ +import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica, prisma } from "~/db.server"; +import { + ProjectAlertEmailProperties, + ProjectAlertSlackProperties, +} from "~/models/projectAlert.server"; +import { + resolveAgentAlertContext, + type AgentAlertContextError, +} from "~/services/dashboardAgentAlertContext.server"; +import { + canUseDashboardAgentEmailAlerts, + DASHBOARD_AGENT_WATCH_ALERT_TYPE, +} from "~/services/dashboardAgentWatchAlerts.server"; +import { logger } from "~/services/logger.server"; +import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server"; +import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server"; + +/** + * `GET /api/v1/dashboard-agent/alerts` — what alerts this chat's project sends + * when a watch fires. + * `POST /api/v1/dashboard-agent/alerts` — subscribe the user's email to them. + * + * Accepts ONLY the dashboard agent's delegated user-actor token, like the watches + * endpoint: POST creates something that later mails a person, so the only caller + * it trusts is the agent acting for the signed-in user in a live chat. The + * environment is the token's, not the body's (see `resolveAgentAlertContext`). + * + * The feature-flag/transport gate is enforced here AND at delivery, and its denial + * carries a machine-readable `reason` so the agent can say why instead of guessing. + */ + +const ListQuerySchema = z.object({ + chatId: z.string().min(1), + environmentId: z.string().min(1).optional(), + projectRef: z.string().min(1).optional(), +}); + +const CreateBodySchema = z.object({ + chatId: z.string().min(1), + channel: z.literal("email"), + /** Omit it: it defaults to, and may only be, the authenticated user's account email. */ + email: z.string().email().optional(), + environmentId: z.string().min(1).optional(), + projectRef: z.string().min(1).optional(), +}); + +/** + * The preamble both handlers share: a dashboard-agent token, plus the environment + * scope the dashboard minted it with — which is the authority for everything here, + * so a token without one can't be used at all. + */ +async function authenticate( + request: Request +): Promise<{ userId: string; environmentId: string } | { error: Response }> { + const authentication = await authenticateUatOrApiRequest(request); + const actor = authentication?.userActor; + if (!actor || actor.client !== "dashboard-agent") { + return { error: json({ error: "Invalid or missing access token" }, { status: 401 }) }; + } + if (!actor.environmentId) { + return { + error: json( + { error: "This chat has no environment context.", code: "invalid_target" }, + { status: 400 } + ), + }; + } + return { userId: actor.userId, environmentId: actor.environmentId }; +} + +/** Context failures: a mismatched claim is the caller's error, the rest are 404s. */ +function contextStatus(code: AgentAlertContextError) { + return code === "environment_mismatch" ? 400 : 404; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const auth = await authenticate(request); + if ("error" in auth) return auth.error; + + const query = ListQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams.entries()) + ); + if (!query.success) { + return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 }); + } + + const context = await resolveAgentAlertContext({ + userId: auth.userId, + environmentId: auth.environmentId, + chatId: query.data.chatId, + claimedEnvironmentId: query.data.environmentId, + claimedProjectRef: query.data.projectRef, + }); + if (!context.ok) { + return json( + { error: context.error, code: context.code }, + { status: contextStatus(context.code) } + ); + } + + const channels = await $replica.projectAlertChannel.findMany({ + where: { + projectId: context.environment.project.id, + alertTypes: { has: DASHBOARD_AGENT_WATCH_ALERT_TYPE }, + }, + select: { id: true, type: true, enabled: true, properties: true, environmentTypes: true }, + orderBy: { createdAt: "asc" }, + }); + + return json({ + alerts: channels.map((channel) => ({ + id: channel.id, + type: channel.type, + enabled: channel.enabled, + environmentTypes: channel.environmentTypes, + target: describeTarget(channel.type, channel.properties), + })), + }); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const auth = await authenticate(request); + if ("error" in auth) return auth.error; + const { userId } = auth; + + let body: z.infer; + try { + const parsed = CreateBodySchema.safeParse(await request.json()); + if (!parsed.success) { + return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 }); + } + body = parsed.data; + } catch { + return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 }); + } + + const context = await resolveAgentAlertContext({ + userId, + environmentId: auth.environmentId, + chatId: body.chatId, + claimedEnvironmentId: body.environmentId, + claimedProjectRef: body.projectRef, + }); + if (!context.ok) { + return json( + { error: context.error, code: context.code }, + { status: contextStatus(context.code) } + ); + } + const { environment } = context; + + const gate = await canUseDashboardAgentEmailAlerts({ + userId, + organizationId: environment.organizationId, + organizationSlug: environment.organization.slug, + projectId: environment.project.id, + }); + if (!gate.allowed) { + return json({ error: "Alerts are not available here", code: gate.reason }, { status: 403 }); + } + + // The agent may only ever subscribe the signed-in user's own account email. + // Omitting it is the normal path; supplying one is accepted only when it IS that + // address, so a model can't be talked into mailing a watch to someone else. + // Read off the primary, not the replica: this is the identity the subscription + // is pinned to. + const user = await prisma.user.findFirst({ where: { id: userId }, select: { email: true } }); + if (!user) { + return json({ error: "User not found", code: "invalid_request" }, { status: 404 }); + } + const email = user.email; + if (body.email && body.email.trim().toLowerCase() !== email.toLowerCase()) { + return json( + { + error: + "Watch alerts can only go to your own account email. Ask the user to add another address on the Alerts page.", + code: "email_not_allowed", + }, + { status: 400 } + ); + } + + try { + const service = new CreateAlertChannelService(); + const channel = await service.call(environment.project.externalRef, userId, { + name: `Watch alerts for ${email}`, + alertTypes: [DASHBOARD_AGENT_WATCH_ALERT_TYPE], + environmentTypes: [environment.type], + // Stable per (email, project): asking twice re-enables the existing + // subscription instead of stacking duplicate channels. + deduplicationKey: `dashboard-agent-watch:${email}`, + channel: { type: "EMAIL", email }, + }); + + return json({ id: channel.id, type: channel.type, target: email, enabled: channel.enabled }); + } catch (error) { + logger.error("Failed to create a dashboard agent watch alert channel", { error }); + return json({ error: "Internal Server Error", code: "internal" }, { status: 500 }); + } +} + +/** A short, non-secret description of where a channel delivers. */ +function describeTarget(type: string, properties: unknown): string | undefined { + if (type === "EMAIL") { + const parsed = ProjectAlertEmailProperties.safeParse(properties); + return parsed.success ? maskEmail(parsed.data.email) : undefined; + } + if (type === "SLACK") { + const parsed = ProjectAlertSlackProperties.safeParse(properties); + return parsed.success ? `#${parsed.data.channelName}` : undefined; + } + // Webhook URLs stay out of the agent's context entirely. + return undefined; +} + +function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + if (!domain || !local) return "an email address"; + const head = local.slice(0, 2); + return `${head}${local.length > 2 ? "…" : ""}@${domain}`; +} diff --git a/apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.check.ts b/apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.check.ts new file mode 100644 index 00000000000..6a69cf28053 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.check.ts @@ -0,0 +1,165 @@ +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { cancelWatch, getWatch, recordWatchCheck } from "@internal/dashboard-agent-db"; +import { z } from "zod"; +import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { logger } from "~/services/logger.server"; +import { checkWatch } from "~/services/dashboardAgentWatchChecks"; +import { watchCheckDeps } from "~/services/dashboardAgentWatchChecks.server"; +import { authorizeWatchEnvironment } from "~/services/dashboardAgentWatches.server"; +import { + WATCH_TOKEN_GRACE_MS, + bearerToken, + verifyWatchTokenFromRequest, +} from "~/services/dashboardAgentWatchToken.server"; + +/** + * `POST /api/v1/dashboard-agent/watches/:watchId/check` — the PRIVATE check + * endpoint. The watcher task calls it once per tick with the watch's token and + * gets back the deterministic verdict plus the facts the wake narration reads. + * + * Order of authority, which is the whole security model of this route: + * + * 1. the TOKEN only names a watch (401 if invalid, 403 if it names a different + * watch than the URL), + * 2. the ROW is the authority on lifecycle — status, deadline, and the immutable + * project/environment/user snapshot; nothing in the request can widen it, + * 3. the USER is re-authorized against that snapshot on EVERY call, and a revoked + * user gets the watch cancelled here, before any environment data is read. + * + * The route does NOT transition the watch to fired/expired, and does NOT advance + * the tick counter. It records what the check observed (`lastCheckedAt` plus the + * `lastResult` the notification reads) and returns the verdict; the watcher task + * owns the fire/expire transition and the delivery, so exactly one component + * decides when the user gets told. The tick counter likewise has exactly one + * writer — the task's generation claim — so nothing here can fork the tick chain. + */ + +const ParamsSchema = z.object({ watchId: z.string().min(1) }); + +const BodySchema = z.object({ + /** The expiry evaluation: allowed after `expiresAt`, within the token's grace. */ + final: z.boolean().optional(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + if (!parsedParams.success) return json({ error: "Invalid params" }, { status: 400 }); + const { watchId } = parsedParams.data; + + const token = bearerToken(request); + if (!token) { + return json( + { error: "Invalid or missing access token", code: "unauthorized" }, + { status: 401 } + ); + } + + const claims = await verifyWatchTokenFromRequest(token); + if (!claims) { + return json( + { error: "Invalid or missing access token", code: "unauthorized" }, + { status: 401 } + ); + } + + // A valid token for a DIFFERENT watch is a distinct failure from a bad token: + // the caller is authenticated, just not for this resource. + if (claims.watchId !== watchId) { + return json({ error: "Not allowed for this watch", code: "watch_mismatch" }, { status: 403 }); + } + + let body: z.infer = {}; + try { + const raw = await request.text(); + if (raw.length > 0) { + const parsedBody = BodySchema.safeParse(JSON.parse(raw)); + if (!parsedBody.success) return json({ error: "Invalid request body" }, { status: 400 }); + body = parsedBody.data; + } + } catch { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const watch = await getWatch(dashboardAgentDb, { id: watchId }); + if (!watch) { + return json({ error: "Watch not found", code: "not_found" }, { status: 404 }); + } + + // Terminal watches are immutable — never checked again, whatever the token says. + if (watch.status !== "active") { + return json( + { + error: `This watch is ${watch.status}`, + code: watch.status === "cancelled" ? "cancelled" : "not_active", + status: watch.status, + }, + { status: 403 } + ); + } + + const now = new Date(); + const expired = watch.expiresAt.getTime() <= now.getTime(); + if (expired) { + // Past the deadline only the FINAL evaluation is allowed, and only inside the + // grace window the token itself is valid for. + const graceEnds = watch.expiresAt.getTime() + WATCH_TOKEN_GRACE_MS; + if (body.final !== true || now.getTime() > graceEnds) { + return json( + { error: "This watch has expired", code: "expired", expiresAt: watch.expiresAt }, + { status: 403 } + ); + } + } + + // Re-authorize the INITIATING user against the watch's immutable + // project/environment. This happens before any environment data is read, so a + // revoked user's tick can't observe anything on the way out. + const authorization = await authorizeWatchEnvironment({ + userId: watch.userId, + organizationId: watch.organizationId, + projectId: watch.projectId, + environmentId: watch.environmentId, + }); + + if (!authorization.ok) { + // Cancel here, atomically, before returning: the watch must not survive the + // access it was created with. Cancellation is never notified, so + // `deliveryStatus` stays `not_required`. + await cancelWatch(dashboardAgentDb, { id: watchId, reason: "access_revoked" }); + return json( + { error: "Access to this environment was revoked", code: "access_revoked" }, + { status: 403 } + ); + } + + const since = watch.spec.since ? new Date(watch.spec.since) : watch.createdAt; + const outcome = await checkWatch( + watch.spec, + watchCheckDeps(authorization.environment, now), + { now, since }, + (error) => logger.error("Dashboard agent watch check failed", { watchId, error }) + ); + + // Record the check even on the final evaluation: it stamps `lastCheckedAt` and + // parks `lastResult` on the row, which is the payload the notification reads. + // Guarded on `active`, so a concurrent fire/expire simply wins and this no-ops. + // Never touches `tickCount` — see the note above. + await recordWatchCheck(dashboardAgentDb, { + id: watchId, + lastResult: { + result: outcome.result, + facts: outcome.facts, + observed: outcome.observed, + final: body.final === true, + }, + }); + + // `observed` travels with the verdict: it is the other half of the resolved + // result (§4.2), and the task writes it onto the row in the SAME statement as + // the resolution, so no delivery surface has to re-read the source (§7.5). + return json({ result: outcome.result, facts: outcome.facts, observed: outcome.observed }); +} diff --git a/apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.fired.ts b/apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.fired.ts new file mode 100644 index 00000000000..9843a22c516 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.fired.ts @@ -0,0 +1,95 @@ +import { getWatch } from "@internal/dashboard-agent-db"; +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { enqueueWatchFiredAlert } from "~/services/dashboardAgentWatchAlerts.server"; +import { authorizeWatchEnvironment } from "~/services/dashboardAgentWatches.server"; +import { + bearerToken, + verifyWatchTokenFromRequest, +} from "~/services/dashboardAgentWatchToken.server"; +import { logger } from "~/services/logger.server"; + +/** + * `POST /api/v1/dashboard-agent/watches/:watchId/fired` — the watcher task tells + * us a watch fired, so the project's alert channels can be notified. + * + * Same security model as the check endpoint next door: the TOKEN only names a + * watch, the ROW is the authority on whether it actually fired, and the watch's + * initiating USER is re-authorized against the row's immutable + * project/environment before anything is sent — an alert must never outlive the + * access the watch was created with. + * + * The route asserts nothing beyond "this row is fired": the caller's body is + * ignored entirely, so a replay can only ever re-announce what the row already + * says. Repeat calls are harmless because the alert job is keyed on the watch + * (`watch-alert:{watchId}`), so the fan-out happens at most once. + */ + +const ParamsSchema = z.object({ watchId: z.string().min(1) }); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + if (!parsedParams.success) return json({ error: "Invalid params" }, { status: 400 }); + const { watchId } = parsedParams.data; + + const token = bearerToken(request); + if (!token) { + return json( + { error: "Invalid or missing access token", code: "unauthorized" }, + { status: 401 } + ); + } + + const claims = await verifyWatchTokenFromRequest(token); + if (!claims) { + return json( + { error: "Invalid or missing access token", code: "unauthorized" }, + { status: 401 } + ); + } + + if (claims.watchId !== watchId) { + return json({ error: "Not allowed for this watch", code: "watch_mismatch" }, { status: 403 }); + } + + const watch = await getWatch(dashboardAgentDb, { id: watchId }); + if (!watch) { + return json({ error: "Watch not found", code: "not_found" }, { status: 404 }); + } + + // The row decides. Anything that isn't a fired watch gets no alert, whatever + // the caller claims. + if (watch.status !== "fired" || !watch.firedAt) { + return json( + { error: `This watch is ${watch.status}`, code: "not_fired", status: watch.status }, + { status: 409 } + ); + } + + const authorization = await authorizeWatchEnvironment({ + userId: watch.userId, + organizationId: watch.organizationId, + projectId: watch.projectId, + environmentId: watch.environmentId, + }); + + if (!authorization.ok) { + // Not cancelled here (the watch is already terminal) — just silence. + logger.info("Dashboard agent watch fired, but access was revoked; no alert", { watchId }); + return json( + { error: "Access to this environment was revoked", code: "access_revoked" }, + { + status: 403, + } + ); + } + + await enqueueWatchFiredAlert(watch, "fired"); + + return json({ ok: true }); +} diff --git a/apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts b/apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts new file mode 100644 index 00000000000..504cd4d19cf --- /dev/null +++ b/apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts @@ -0,0 +1,236 @@ +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { watchSpecSchema } from "@internal/dashboard-agent-contracts"; +import { z } from "zod"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { $replica } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { + canUseDashboardAgentAlerts, + DASHBOARD_AGENT_WATCH_ALERT_TYPE, +} from "~/services/dashboardAgentWatchAlerts.server"; +import { + authorizeWatchEnvironmentById, + createDashboardAgentWatch, + resolveChatWatchContext, +} from "~/services/dashboardAgentWatches.server"; +import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server"; + +/** + * `POST /api/v1/dashboard-agent/watches` — the agent's `schedule_watch` adapter. + * + * Accepts ONLY the dashboard agent's delegated user-actor token. Unlike the other + * UAT routes it does not also take a PAT: this endpoint creates something that + * later runs in the background on a user's behalf, so the only caller it trusts is + * the agent acting for the signed-in user in a live chat. + * + * Order of authority: + * + * - the ENVIRONMENT comes from the TOKEN, which the dashboard minted for the + * environment the current turn is being taken in. Nothing in the request body + * can widen or move it: an `environmentId`/`projectRef` that disagrees is a + * 400, and a token with no environment scope can't create a watch at all. A + * chat's stored context is never consulted — it's a snapshot from whenever the + * chat started, and would silently bind a watch to a stale environment. + * - the CHAT must still be a live chat owned by that user, or a watch could be + * bound to (and later wake) someone else's conversation. That scoped read also + * yields the chat's org, which the token's environment must match. + * - the environment is then re-authorized through the same path a background + * check uses, so a watch is only created for an environment its user can reach + * right now. + */ + +const BodySchema = z.object({ + spec: watchSpecSchema, + chatId: z.string().min(1), + /** + * The resolution action the user consented to at creation (§6): after an + * attention outcome the wake turn may open an investigation. Off unless the + * caller sends it — the agent may only send it on an explicit ask. + */ + investigateOnAttention: z.boolean().optional(), + /** + * Echoes of the turn's environment, if the caller sends them. Not overrides: + * they're only ever checked against the token's environment scope, which is the + * canonical `RuntimeEnvironment.id` (VERDICTS §3). + */ + projectRef: z.string().min(1).optional(), + environmentId: z.string().min(1).optional(), +}); + +export async function action({ request }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const authentication = await authenticateUatOrApiRequest(request); + if (!authentication?.userActor) { + return json({ error: "Invalid or missing access token" }, { status: 401 }); + } + if (authentication.userActor.client !== "dashboard-agent") { + return json({ error: "Not allowed", code: "forbidden_client" }, { status: 403 }); + } + const userId = authentication.userActor.userId; + // The environment this turn is scoped to. Absent means the turn was minted + // without one, and there is nothing to fall back to that we'd trust. + const environmentId = authentication.userActor.environmentId; + if (!environmentId) { + return json( + { error: "This chat has no environment context to watch in.", code: "invalid_target" }, + { status: 400 } + ); + } + + let parsed: z.infer; + try { + const result = BodySchema.safeParse(await request.json()); + if (!result.success) { + return json({ error: "Invalid watch request", code: "invalid_request" }, { status: 400 }); + } + parsed = result.data; + } catch { + return json({ error: "Invalid watch request", code: "invalid_request" }, { status: 400 }); + } + + // A body that names a different environment than the turn is a bug or an + // attempt to move the watch — either way, refuse rather than silently pick one. + if (parsed.environmentId && parsed.environmentId !== environmentId) { + return json( + { + error: "That environment isn't the one this chat is open in.", + code: "environment_mismatch", + }, + { status: 400 } + ); + } + + try { + // Ownership — a chat this user doesn't own doesn't exist as far as this + // endpoint is concerned, and nothing is written. + const chat = await resolveChatWatchContext({ chatId: parsed.chatId, userId }); + if (!chat) { + return json({ error: "Chat not found", code: "chat_not_found" }, { status: 404 }); + } + + // The same authorization a background check applies, so a watch is only ever + // created for an environment its user can reach right now. + const environment = await authorizeWatchEnvironmentById({ userId, environmentId }); + if (!environment) { + return json({ error: "Environment not found", code: "invalid_target" }, { status: 404 }); + } + // A chat belongs to one org; its watches can't point at another org's env. + if (environment.organizationId !== chat.organizationId) { + return json({ error: "Environment not found", code: "invalid_target" }, { status: 404 }); + } + // Same check as `environmentId`, for callers that send the project instead. + if (parsed.projectRef && environment.project.externalRef !== parsed.projectRef) { + return json( + { + error: "That project isn't the one this chat is open in.", + code: "environment_mismatch", + }, + { status: 400 } + ); + } + + const result = await createDashboardAgentWatch({ + environment, + userId, + chatId: parsed.chatId, + spec: parsed.spec, + investigateOnAttention: parsed.investigateOnAttention, + }); + + if (!result.ok) { + const status = + result.code === "limit_reached" || result.code === "duplicate" + ? 409 + : result.code === "invalid_target" + ? 404 + : // The chat was deleted while the create was in flight. + result.code === "chat_not_found" + ? 404 + : result.code === "not_configured" + ? 501 + : 500; + return json( + { + error: result.error, + code: result.code, + ...(result.existingId ? { existingId: result.existingId } : {}), + }, + { status } + ); + } + + // The one-shot result block (§2.2/§4.1): the immediate check answered the + // request, so there is no watch, no id, and nothing to cancel. The caller + // renders it as a deterministic result block and the agent answers from it. + if (!result.watching) { + return json({ + watching: false, + identity: result.identity, + immediate: { result: result.immediate.result, facts: result.immediate.facts }, + }); + } + + return json({ + watching: true, + watchId: result.watchId, + identity: result.identity, + status: result.status, + expiresAt: result.expiresAt.toISOString(), + emailAlerts: await resolveEmailAlertsState({ userId, environment }), + ...(result.unavailable ? { unavailable: true } : {}), + }); + } catch (error) { + logger.error("Failed to create a dashboard agent watch", { error }); + return json({ error: "Internal Server Error", code: "internal" }, { status: 500 }); + } +} + +/** + * Whether a fired watch in this environment would already reach the user outside + * the chat, so the agent knows whether to offer an email alert when it confirms a + * new watch: + * + * - `subscribed` — an enabled channel here already subscribes to watch fires + * (any channel type counts: email, Slack, webhook), so there + * is nothing to offer. + * - `unavailable` — the plan or feature gate denies alerts. Say nothing: don't + * advertise what the user can't have. + * - `none` — alerts are possible and nothing is subscribed yet. + * + * Advisory only. This annotates a watch that is already created, so every failure + * is `none` — the quiet answer — and never turns into a failed creation. + */ +async function resolveEmailAlertsState(params: { + userId: string; + environment: AuthenticatedEnvironment; +}): Promise<"subscribed" | "none" | "unavailable"> { + const { userId, environment } = params; + try { + // The same predicate the delivery job selects channels with, so "subscribed" + // means a fire would actually be delivered. + const channel = await $replica.projectAlertChannel.findFirst({ + where: { + projectId: environment.project.id, + enabled: true, + alertTypes: { has: DASHBOARD_AGENT_WATCH_ALERT_TYPE }, + environmentTypes: { has: environment.type }, + }, + select: { id: true }, + }); + if (channel) return "subscribed"; + + const gate = await canUseDashboardAgentAlerts({ + userId, + organizationId: environment.organizationId, + organizationSlug: environment.organization.slug, + orgFeatureFlags: environment.organization.featureFlags as Record | null, + }); + return gate.allowed ? "none" : "unavailable"; + } catch (error) { + logger.error("Failed to resolve dashboard agent watch alert state", { error }); + return "none"; + } +} diff --git a/apps/webapp/app/routes/resources.dashboard-agent.alerts.$channelId.unsubscribe.tsx b/apps/webapp/app/routes/resources.dashboard-agent.alerts.$channelId.unsubscribe.tsx new file mode 100644 index 00000000000..4777e4666b8 --- /dev/null +++ b/apps/webapp/app/routes/resources.dashboard-agent.alerts.$channelId.unsubscribe.tsx @@ -0,0 +1,155 @@ +import { EnvelopeIcon } from "@heroicons/react/24/solid"; +import { Form, useNavigation } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { FormTitle } from "~/components/primitives/FormTitle"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { verifyUnsubscribeToken } from "~/services/dashboardAgentAlertUnsubscribeToken.server"; +import { + DASHBOARD_AGENT_WATCH_ALERT_TYPE, + unsubscribeChannelFromWatchAlerts, +} from "~/services/dashboardAgentWatchAlerts.server"; +import { rootPath } from "~/utils/pathBuilder"; + +/** + * The "Turn off these alerts" link in a watch alert email. + * + * The signed token in the query string is the whole authorization — no session, + * because the recipient of an alert email is not necessarily signed in on the + * device they read it on. It names one channel and one alert type, so this route + * can do exactly one thing. + * + * GET confirms, POST acts: a bare GET must not mutate, or a link preview or mail + * scanner would silently unsubscribe someone. + */ + +const ParamsSchema = z.object({ channelId: z.string().min(1) }); + +async function authorize(request: Request, params: Record) { + const parsedParams = ParamsSchema.safeParse(params); + if (!parsedParams.success) return undefined; + + const token = new URL(request.url).searchParams.get("token"); + if (!token) return undefined; + + const claims = await verifyUnsubscribeToken(token); + if (!claims) return undefined; + if (claims.channelId !== parsedParams.data.channelId) return undefined; + if (claims.alertType !== DASHBOARD_AGENT_WATCH_ALERT_TYPE) return undefined; + + return claims; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const claims = await authorize(request, params); + // The token travels back into the form's action: Remix drops the current + // search params from a bare `
`, and the POST needs it too. + return typedjson({ + valid: claims !== undefined, + formAction: `${new URL(request.url).pathname}${new URL(request.url).search}`, + }); +} + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return typedjson({ success: false as const, message: "Method not allowed" }, { status: 405 }); + } + + const claims = await authorize(request, params); + if (!claims) { + return typedjson( + { + success: false as const, + message: "This link is no longer valid, so we couldn't turn off the alerts.", + }, + { status: 403 } + ); + } + + const result = await unsubscribeChannelFromWatchAlerts(claims.channelId); + if (!result.ok) { + return result.reason === "conflict" + ? typedjson( + { + success: false as const, + message: "This alert was being changed elsewhere. Please try again.", + }, + { status: 409 } + ) + : typedjson( + { success: false as const, message: "This alert no longer exists." }, + { status: 404 } + ); + } + + return typedjson({ success: true as const, channelName: result.channelName }); +} + +export default function Page() { + const { valid, formAction } = useTypedLoaderData(); + const result = useTypedActionData(); + const navigation = useNavigation(); + const isLoading = navigation.state !== "idle"; + + if (result?.success) { + return ( + + + {result.channelName} will no longer be alerted when a watch fires. You can turn it back on + from the Alerts page in your project. + + + Dashboard + + + ); + } + + if (!valid || result?.success === false) { + return ( + + + {result?.success === false + ? result.message + : "This link is no longer valid. You can manage alerts from the Alerts page in your project."} + + + Dashboard + + + ); + } + + return ( + + + This stops the alerts this channel receives when a watch you set up with the dashboard agent + fires. Other alerts on the channel are unaffected. + + + + + + ); +} + +function Shell({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + +
+ } + title={title} + /> + {children} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts index cb40f3804af..bd569bca721 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts @@ -18,9 +18,9 @@ import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server"; // signed-in user and inject it into the turn's metadata server-side. The token // reaches the agent without ever touching the browser, and minting stays tied // to the user's own session (no shared-secret backdoor). The token is scoped to -// the environment in this URL, which is what the agent's environment-bound -// endpoints read — so a turn can only ever act in the environment the user is -// actually looking at. +// the environment in this URL, which is what the agent's write-ish endpoints +// (watches, watch alerts) bind to — so a turn can only ever act in the +// environment the user is actually looking at. // // The append body is `{ kind, payload: { metadata, ... } }`; we add the token // (plus the API origin and the server-vouched project ref + env) to diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index 0cf15bc4216..9d31baeec2e 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -1,16 +1,27 @@ import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { + appendChatMessage, + cancelWatch, chatExists, + countUnreadWatchWakes, countUserMessages, createChat, getChatMessages, getSession, + getWatch, listChatIdsWithOpenInvestigations, + listChatIdsWithUnreadWakes, listChats, + listUnreadWatchWakes, + markChatRead, renameChat, setChatPinned, - softDeleteChat, } from "@internal/dashboard-agent-db"; +import { + VIEW_BLOCK_VERSION, + watchDraftSchema, + type WatchDraft, +} from "@internal/dashboard-agent-contracts"; import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic"; import type { UIMessage } from "ai"; import { z } from "zod"; @@ -18,6 +29,18 @@ import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { + watchConfirmationBlockBody, + watchOneShotBlockBody, + watchSubjectLabel, +} from "~/components/dashboard-agent/watch-presentation"; +import { subscribeUserToWatchAlerts } from "~/services/dashboardAgentWatchAlerts.server"; +import { + authorizeWatchEnvironmentById, + createDashboardAgentWatch, + deleteChatWithWatches, + listActiveWatchesForChats, +} from "~/services/dashboardAgentWatches.server"; import { dashboardAgentApiOrigin, isDashboardAgentConfigured, @@ -43,7 +66,18 @@ const ENV_NAME_BY_TYPE: Record = { }; const ActionBody = z.object({ - intent: z.enum(["start", "create", "token", "rename", "pin", "delete", "resolve"]), + intent: z.enum([ + "start", + "create", + "token", + "rename", + "pin", + "delete", + "read", + "resolve", + "watch-cancel", + "watch-create", + ]), // Omitted for `create` (the server generates it); required for the rest. chatId: z.string().min(1).optional(), // The first user message (JSON UIMessage), for `create`. @@ -53,10 +87,17 @@ const ActionBody = z.object({ pinned: z.enum(["true", "false"]).optional(), // A `trigger://` URI, for `resolve`. uri: z.string().optional(), + // The watch to cancel, for `watch-cancel`. + watchId: z.string().min(1).optional(), + // The configured card, for `watch-create`: a JSON `WatchDraft`. + draft: z.string().optional(), }); // History list, or — with ?chatId= — the stored transcript + session for resume, -// or — with ?quota=1 — how many messages the user has sent, for the Free-plan cap. +// or — with ?unread=1 — just the unread wake count plus the capped list of those +// wakes (the launcher's dot and the wake toast poll it while the panel is closed, +// so it must stay cheap), or — with ?quota=1 — how many messages the user has +// sent, for the Free-plan cap. export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const userId = user.id; @@ -78,6 +119,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const searchParams = new URL(request.url).searchParams; + if (searchParams.get("unread") === "1") { + // The count drives the dot, the capped list drives one toast per wake. + const [unreadWakes, wakes] = await Promise.all([ + countUnreadWatchWakes(dashboardAgentDb, { + organizationId: project.organizationId, + userId, + }), + listUnreadWatchWakes(dashboardAgentDb, { + organizationId: project.organizationId, + userId, + }), + ]); + return json({ unreadWakes, wakes }); + } + // How many messages the user has sent, for the Free-plan cap. `chatId` is the // chat the panel has open: its messages are excluded here and counted from the // live transcript instead, so a turn that hasn't been persisted yet still @@ -105,20 +161,45 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { userId, }); - // Which chats are mid-investigation — one query for ALL the listed chats, - // because the history list must not fan out a query per row. - const investigatingChatIds = await listChatIdsWithOpenInvestigations(dashboardAgentDb, { - organizationId: project.organizationId, - userId, - }); + // Active-watch chips for the list, which chats woke unseen, and which are + // mid-investigation — one query each for ALL the listed chats, because the + // history list must not fan out a query per row. + const [watchesByChat, unreadWakes, unreadChatIds, investigatingChatIds] = await Promise.all([ + listActiveWatchesForChats({ + chatIds: chats.map((chat) => chat.id), + organizationId: project.organizationId, + userId, + }), + countUnreadWatchWakes(dashboardAgentDb, { + organizationId: project.organizationId, + userId, + }), + listChatIdsWithUnreadWakes(dashboardAgentDb, { + organizationId: project.organizationId, + userId, + }), + listChatIdsWithOpenInvestigations(dashboardAgentDb, { + organizationId: project.organizationId, + userId, + }), + ]); return json({ - chats: chats.map((chat) => ({ - ...chat, - // A row marker in the history list: the chat has something running in it. - // Derived here so the list doesn't re-derive per render. - hasOpenInvestigation: investigatingChatIds.has(chat.id), - })), + chats: chats.map((chat) => { + const watches = watchesByChat[chat.id] ?? []; + return { + ...chat, + watches, + hasUnreadWake: unreadChatIds.has(chat.id), + // Both are row markers in the history list: the chat has something + // running in it. Derived here so the list doesn't re-derive per render. + // The list now carries fired/expired watches too (the wake banner needs + // their kind), so "something is running" means active specifically. + hasActiveWatch: watches.some((watch) => watch.status === "active"), + hasOpenInvestigation: investigatingChatIds.has(chat.id), + }; + }), + unreadWakes, }); }; @@ -267,6 +348,148 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); } + // The configuration card's submit path (§7.6). The card is a deterministic UI + // block, not a chat turn: nothing is written until this action runs, and what it + // writes is the ONE thing the transcript keeps — a confirmation (a watch is + // running) or a one-shot result (the immediate check already answered). + // + // This is an ADAPTER (§7.3), so it authorizes and hands `createDashboardAgentWatch` + // an already-authorized context. The environment comes from the URL the user is + // on, resolved through the same re-authorization a background tick passes — never + // from the request body, and never from the chat's stored context. + if (parsed.data.intent === "watch-create") { + let draft: WatchDraft; + try { + const result = watchDraftSchema.safeParse(JSON.parse(parsed.data.draft ?? "")); + if (!result.success) { + return json({ error: "That watch isn't valid.", code: "invalid_request" }, { status: 400 }); + } + draft = result.data; + } catch { + return json({ error: "That watch isn't valid.", code: "invalid_request" }, { status: 400 }); + } + + const runtimeEnv = await findEnvironmentBySlug(project.id, envParam, userId); + if (!runtimeEnv) return json({ error: "Environment not found" }, { status: 404 }); + + const environment = await authorizeWatchEnvironmentById({ + userId, + environmentId: runtimeEnv.id, + }); + if (!environment) { + return json({ error: "Environment not found", code: "invalid_target" }, { status: 404 }); + } + + // A watch is chat-bound (§7.3), so a card submitted from a fresh panel needs a + // chat to belong to. Created here, with no first message and no agent run: the + // confirmation block below is the whole conversation until the user types. + let targetChatId = parsed.data.chatId; + if (targetChatId) { + if ( + !(await chatExists(dashboardAgentDb, { + chatId: targetChatId, + userId, + organizationId: project.organizationId, + })) + ) { + return json({ error: "Chat not found", code: "chat_not_found" }, { status: 404 }); + } + } else { + targetChatId = generateFriendlyId("chat"); + await createChat(dashboardAgentDb, { + id: targetChatId, + organizationId: project.organizationId, + userId, + title: `Watch ${watchSubjectLabel(draft.spec)}`, + }); + } + + const result = await createDashboardAgentWatch({ + environment, + userId, + chatId: targetChatId, + spec: draft.spec, + investigateOnAttention: draft.followUp.investigateOnAttention, + }); + + // Validation, cap and network errors stay inside the ephemeral card and + // persist nothing (§2.2 step 5) — so this returns before any append. + if (!result.ok) { + const status = + result.code === "limit_reached" || result.code === "duplicate" + ? 409 + : result.code === "invalid_target" || result.code === "chat_not_found" + ? 404 + : result.code === "not_configured" + ? 501 + : 500; + return json( + { + error: result.error, + code: result.code, + ...(result.existingId ? { existingId: result.existingId } : {}), + }, + { status } + ); + } + + // The external subscription is an EXTRA (§6): it is attached after the watch + // exists and its refusal never fails the creation — the in-dashboard signal + // always works, and the confirmation simply doesn't claim an email. + let notifiedExternally = false; + if (result.watching && draft.followUp.notifyExternally) { + const subscribed = await subscribeUserToWatchAlerts({ userId, environment }); + notifiedExternally = subscribed.ok; + } + + const body = result.watching + ? watchConfirmationBlockBody({ + spec: draft.spec, + watchId: result.watchId, + unavailable: result.unavailable, + followUp: { + investigateOnAttention: draft.followUp.investigateOnAttention, + notifyExternally: notifiedExternally, + }, + }) + : watchOneShotBlockBody({ + spec: draft.spec, + result: result.immediate.result as "satisfied" | "terminal_unsatisfied", + }); + + // The block goes through the same envelope + `ViewBlocks` machinery every + // other card uses. `id` is the watch (or the identity, for a one-shot), so a + // retried submit replaces the block rather than stacking a second one. + const message = { + id: `watch-card:${result.watching ? result.watchId : result.identity}`, + role: "assistant" as const, + parts: [ + { + type: "data-view" as const, + data: { + blocks: [ + { + ...body, + revision: 0, + version: VIEW_BLOCK_VERSION, + id: `watch:${result.watching ? result.watchId : result.identity}`, + }, + ], + }, + }, + ], + }; + + await appendChatMessage(dashboardAgentDb, { chatId: targetChatId, userId, message }); + + return json({ + chatId: targetChatId, + watching: result.watching, + watchId: result.watching ? result.watchId : null, + message, + }); + } + const { intent, chatId } = parsed.data; if (!chatId) return json({ error: "chatId is required" }, { status: 400 }); @@ -341,9 +564,44 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json({ ok: true }); } + // The user has this chat in front of them, so its watch wakes are seen. The + // update is owner-scoped, so a chatId the caller doesn't own is a no-op. + case "read": { + await markChatRead(dashboardAgentDb, { chatId, userId }); + return json({ ok: true }); + } + case "delete": { - // The org scope lives here: `softDeleteChat` is owner-scoped but takes no - // org, so a chat from another org in the URL must 404 before it. + // The org scope lives here: `deleteChatWithWatches` is owner-scoped but + // takes no org, so a chat from another org in the URL must 404 before it. + if ( + !(await chatExists(dashboardAgentDb, { + chatId, + userId, + organizationId: project.organizationId, + })) + ) { + return json({ error: "Chat not found" }, { status: 404 }); + } + // A deleted chat has nowhere to deliver a watch outcome, so the delete and + // the watch cancellations land in one transaction. + const { cancelledWatches } = await deleteChatWithWatches({ chatId, userId }); + return json({ ok: true, cancelledWatches }); + } + + // Stop watching, from the chip's ×. Ownership goes through the chat: the watch + // must belong to the chat named in the request, and that chat must belong to + // this user in this org — so a watch id alone can never cancel someone else's + // watch. + case "watch-cancel": { + const watchId = parsed.data.watchId; + if (!watchId) return json({ error: "watchId is required" }, { status: 400 }); + + const watch = await getWatch(dashboardAgentDb, { id: watchId }); + if (!watch || watch.chatId !== chatId) { + return json({ error: "Watch not found" }, { status: 404 }); + } + if ( !(await chatExists(dashboardAgentDb, { chatId, @@ -353,7 +611,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ) { return json({ error: "Chat not found" }, { status: 404 }); } - await softDeleteChat(dashboardAgentDb, { chatId, userId }); + + // A watch that already fired or expired keeps its outcome — `cancelWatch` + // only touches an active row, so this is a no-op then, not an error. + await cancelWatch(dashboardAgentDb, { id: watchId, reason: "user" }); return json({ ok: true }); } } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 29228341b67..d642d5dfd74 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -33,6 +33,9 @@ import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { InvestigateButton } from "~/components/dashboard-agent/InvestigateButton"; +import { WatchButton } from "~/components/dashboard-agent/WatchButton"; +import { isFinalRunStatus } from "~/v3/taskStatus"; +import { runWatchRecommendation } from "~/components/dashboard-agent/watch-recommendations"; import { failedRunPrompt, isFailedRunStatus, @@ -1145,6 +1148,12 @@ function RunBody({ runFriendlyId={run.friendlyId} /> ) : null} + {/* The universal `Watch…` entry (§2.1), pre-filled with this run's + recommendation: tell me when it finishes. Only while the run can + still change — a finished run has nothing left to wait for. */} + {isFinalRunStatus(run.status) ? null : ( + + )} {run.error && ( diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts index b96810ac07d..895cdb631a7 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -23,6 +23,9 @@ export type GalleryGroup = | "investigation" | "report" | "chart" + | "watches" + | "watch-card" + | "wakes" | "hero" | "prompts" | "intents" @@ -50,6 +53,9 @@ export const GALLERY_GROUPS: { group: GalleryGroup; label: string }[] = [ { group: "investigation", label: "Investigation card" }, { group: "report", label: "Report card" }, { group: "chart", label: "Chart card" }, + { group: "watches", label: "Watch chips" }, + { group: "watch-card", label: "Watch card" }, + { group: "wakes", label: "Wake banners" }, { group: "hero", label: "Blank-state hero" }, { group: "prompts", label: "Suggested prompts" }, { group: "intents", label: "Intent bubbles" }, @@ -224,6 +230,63 @@ export const MANIFEST: GallerySection[] = [ }, { sectionId: "chart-empty", title: "Empty — no data to display", group: "chart" }, + // --- Watch chips --------------------------------------------------------- + { sectionId: "watches-active", title: "Active — cancellable", group: "watches" }, + { sectionId: "watches-fired", title: "Fired", group: "watches" }, + { sectionId: "watches-expired", title: "Expired", group: "watches" }, + { sectionId: "watches-cancelled", title: "Cancelled", group: "watches" }, + { sectionId: "watches-all-states", title: "All four states in one row", group: "watches" }, + { sectionId: "watches-live", title: "The panel's own chips (real component)", group: "watches" }, + + // --- Watch card ---------------------------------------------------------- + // The configuration card and the two blocks a submitted card leaves behind. + // The card is ephemeral (it never enters the transcript), so the states here + // are the whole of what a user can see of it. + { + 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-create-failure", title: "Create failure", group: "watch-card" }, + { sectionId: "watch-card-confirmation", title: "Confirmation block", group: "watch-card" }, + { + sectionId: "watch-card-one-shot-satisfied", + title: "One-shot result — already true", + group: "watch-card", + }, + { + sectionId: "watch-card-one-shot-impossible", + title: "One-shot result — can't happen now", + group: "watch-card", + }, + { + sectionId: "watch-card-toast-headline", + title: "Wake toast headline (fact first)", + group: "watch-card", + }, + + // --- Wake banners -------------------------------------------------------- + // A wake narration through the production renderer: the banner plus the prose + // the agent wrote, as the panel shows them. + { sectionId: "wake-positive", title: "Positive", group: "wakes" }, + { sectionId: "wake-attention", title: "Attention", group: "wakes" }, + { + sectionId: "wake-attention-failed-run", + title: "Attention — a run that finished badly", + group: "wakes", + }, + { sectionId: "wake-window-completed", title: "Window completed", 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: "wake-unknown-watch", + title: "Fired, watch not in hand — kind-agnostic", + group: "wakes", + }, + // --- Blank-state hero ----------------------------------------------------- // The new-chat state, with the composer inside the hero. Both widths it ships // at: the 380px side panel and the fullscreen takeover's centred column. @@ -292,6 +355,7 @@ export const MANIFEST: GallerySection[] = [ group: "intents", }, { sectionId: "intent-navigate-run", title: "Navigate — one run", group: "intents" }, + { sectionId: "intent-watch", title: "Watch started", group: "intents" }, { sectionId: "intent-ask", title: "Ask — follow-up handed back", group: "intents" }, { sectionId: "intent-rejected-propose-fix", diff --git a/apps/webapp/app/routes/storybook.agent-ui/route.tsx b/apps/webapp/app/routes/storybook.agent-ui/route.tsx index 526714105a0..91230f5e758 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -21,6 +21,7 @@ import { DemoIntentBubble, DemoInvestigationCard, DemoReportCard, + DemoWatchChips, type DemoItem, } from "~/components/dashboard-agent/demo"; import { DashboardAgentComposer } from "~/components/dashboard-agent/DashboardAgentComposer"; @@ -34,6 +35,27 @@ import { ReportView } from "~/components/dashboard-agent/ReportView"; import { RunDiagnosisCard } from "~/components/dashboard-agent/RunDiagnosisCard"; import { resolveSuggestedPrompts } from "~/components/dashboard-agent/suggested-prompts"; import { ViewBlocks } from "~/components/dashboard-agent/view-catalog"; +import type { WakeWatch } from "~/components/dashboard-agent/WakeBanner"; +import { WatchCard } from "~/components/dashboard-agent/WatchCard"; +import { + watchDraftFor, + withFollowUp, + withThreshold, + withVariant, +} from "~/components/dashboard-agent/watch-card"; +import { WatchChips, type WatchChip } from "~/components/dashboard-agent/WatchChips"; +import { + watchConfirmationBlockBody, + watchOneShotBlockBody, +} from "~/components/dashboard-agent/watch-presentation"; +import { + errorWatchRecommendation, + healthWatchRecommendation, + 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 { Header1, Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { cn } from "~/utils/cn"; @@ -439,7 +461,7 @@ function Missing({ what }: { what: string }) { // The state map. Keyed by `sectionId`, so the manifest drives what renders. // --------------------------------------------------------------------------- -const { demoInvestigations, demoIntents, demoPageContexts } = demoFixtures; +const { demoInvestigations, demoIntents, demoWatches, demoPageContexts } = demoFixtures; // A stand-in for whatever the `promotedDashboardAgentPrompt` flag holds in // production — the point of the state is the styling of the top slot. @@ -536,6 +558,204 @@ function investigationBlock( }; } +/** A watch fixture in the shape the panel's loader hands to `WatchChips`. */ +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, + }; +} + +// --------------------------------------------------------------------------- +// Watch card fixtures. The card is pure — draft in, callbacks out — so every +// state here is a fixed draft plus a `noop` onChange: nothing on this page +// edits, and nothing is submitted. The drafts come from the same +// recommendation helpers the real Watch… action uses, so what the gallery shows +// is the condition each object actually proposes. +// --------------------------------------------------------------------------- + +const queueWatchDraft = watchDraftFor(queueWatchRecommendation("email-sends")); + +// A run watch with the "investigate if it turns out badly" opt-in already set, +// so the expanded state shows a checked box rather than two empty ones. +const runWatchDraft = withFollowUp(watchDraftFor(runWatchRecommendation("run_a1b2c3d4e5")), { + investigateOnAttention: true, +}); + +// A threshold the schema refuses. No `error` prop: the point of the state is the +// card's OWN validation path (`watchDraftError`), which blocks the submit before +// anything reaches the server. +const invalidThresholdDraft = withThreshold( + withVariant(queueWatchDraft, "queue_depth_above"), + Number.NaN +); + +/** The envelope a host-emitted `watch_result` block carries into the transcript. */ +const WATCH_BLOCK_ENVELOPE = { + id: "watch:watch_demo", + revision: 0, + version: VIEW_BLOCK_VERSION, +} as const; + +const watchConfirmationBlock = { + ...watchConfirmationBlockBody({ + spec: queueWatchRecommendation("email-sends"), + watchId: "watch_demo", + followUp: { investigateOnAttention: true, notifyExternally: true }, + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +const watchSatisfiedBlock = { + ...watchOneShotBlockBody({ + spec: runWatchRecommendation("run_a1b2c3d4e5"), + result: "satisfied", + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +const watchImpossibleBlock = { + ...watchOneShotBlockBody({ + spec: runWatchRecommendation("run_a1b2c3d4e5"), + result: "terminal_unsatisfied", + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +/** + * The toast is a sonner portal, so it can't be rendered inline in a section — + * what the gallery can show is the thing worth reviewing: the headline the + * presenter produces, fact first, with the note the toast puts under it. + */ +const toastWakes: WatchWake[] = [ + { + watchId: "watch_queue", + chatId: "chat_demo", + outcome: "fired", + note: "tell me when the email-sends backlog clears", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + resolution: "condition_met", + observedOutcome: { kind: "backlog_drain", verified: true, depth: 0 }, + }, + { + watchId: "watch_run_failed", + chatId: "chat_demo", + outcome: "fired", + note: "ping me when the nightly backfill finishes", + kind: "run_finished", + identity: "run_finished:run_a1b2c3d4e5", + resolution: "condition_met", + observedOutcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 812_000, + }, + }, +]; + +function WakeToastHeadlines({ wakes }: { wakes: WatchWake[] }) { + return ( +
+ {wakes.map((wake) => ( +
+

{wake.kind}

+

{watchWakeToastTitle(wake)}

+

{wake.note}

+
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Wake fixtures, written here rather than pulled from the demo conversations: a +// wake is a message *id* plus the watch it names, and no demo chat carries one. +// --------------------------------------------------------------------------- + +/** A wake narration, in the shape the panel merges live stream and history into. */ +function wakeMessage(watchId: string, outcome: "fired" | "expired", text: string): UIMessage { + return { + id: `wake:watch:${watchId}:${outcome}`, + role: "assistant", + parts: [{ type: "text", text }], + }; +} + +// One watch per presentation category, plus the pair that proves the point of +// the resolution model: the SAME `condition_met` on the same kind, presented as +// a success and as a failure, decided only by the observed final status. +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_run", + kind: "run_finished", + note: "ping me when the nightly backfill finishes", + identity: "run_finished:run_a1b2c3d4e5", + resolution: "window_completed", + observedOutcome: { kind: "run_finished", verified: true, finalStatus: null, durationMs: null }, + }, + { + id: "watch_run_failed", + kind: "run_finished", + note: "ping me when the nightly backfill finishes", + identity: "run_finished:run_a1b2c3d4e5", + resolution: "condition_met", + observedOutcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 812_000, + }, + }, + { + 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 }, + }, +]; + +/** One wake through the production renderer, with the watches the panel would have. */ +function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeWatch[] }) { + return ( +
+ +
+ ); +} + /** * The actions the executor would attach to each settled card — written out here * because they are server-decided (§6) and the fixtures deliberately don't carry @@ -746,6 +966,145 @@ const STATES: Record = { ), "chart-empty": , + // --- Watch chips -------------------------------------------------------- + "watches-active": , + "watches-fired": , + "watches-expired": , + "watches-cancelled": , + "watches-all-states": , + // The real panel component, fed the same fixtures through the shape its loader + // hands over — so its labels (derived from the watch identity) and the demo + // chips above can be compared side by side. + "watches-live": , + + // --- Watch card --------------------------------------------------------- + "watch-card-compact": ( + + ), + // Customize expands the same block in place — never a second surface. + "watch-card-expanded": ( + + ), + // Expanded so the field the message is about is on screen with it. + "watch-card-validation-error": ( + + ), + // The submit is in flight: the card stays put, disabled, so nothing moves. + "watch-card-pending": ( + + ), + // A refusal from the server. It stays inside the card, and the draft survives. + "watch-card-create-failure": ( + + ), + // What a submitted card leaves in the transcript, built by the same presenter + // the host freezes into the block — so the gallery shows the real wording. + "watch-card-confirmation": , + "watch-card-one-shot-satisfied": , + "watch-card-one-shot-impossible": , + "watch-card-toast-headline": , + + // --- Wake banners ------------------------------------------------------- + "wake-positive": ( + + ), + "wake-attention": ( + + ), + // The answer the resolution model insists is an answer: the window ran out + // with the condition still not true, and that is what the user asked to know. + "wake-window-completed": ( + + ), + // Same kind, same `condition_met`, opposite presentation. The icon follows the + // outcome, never the resolution. + "wake-attention-failed-run": ( + + ), + "wake-neutral-impossible": ( + + ), + "wake-unverified": ( + + ), + // No watches in hand (an older chat, or a watch already swept away): the + // banner still fires, without claiming an outcome it can't know. + "wake-unknown-watch": ( + + ), + // --- Blank-state hero --------------------------------------------------- "hero-panel": , "hero-panel-contextual": , @@ -775,6 +1134,7 @@ const STATES: Record = { ), "intent-navigate-run": , + "intent-watch": , "intent-ask": , "intent-rejected-propose-fix": ( diff --git a/apps/webapp/app/services/dashboardAgent.server.ts b/apps/webapp/app/services/dashboardAgent.server.ts index d377cfa515d..0b6d84de9f8 100644 --- a/apps/webapp/app/services/dashboardAgent.server.ts +++ b/apps/webapp/app/services/dashboardAgent.server.ts @@ -40,8 +40,8 @@ export function dashboardAgentApiOrigin(): string { // // `environmentId` scopes the token to the environment the turn is being taken // in, resolved here from the URL against the user's own session. Endpoints that -// bind something to one environment read it off the token, so the agent can't -// name a different one in a request body. +// bind something to one environment (watches, watch alerts) read it off the +// token, so the agent can't name a different one in a request body. export function mintDashboardAgentUserActorToken( userId: string, opts: { environmentId?: string } = {} diff --git a/apps/webapp/app/services/dashboardAgentAlertContext.server.ts b/apps/webapp/app/services/dashboardAgentAlertContext.server.ts new file mode 100644 index 00000000000..eb35f33c4f7 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentAlertContext.server.ts @@ -0,0 +1,68 @@ +/** + * The context resolution the agent's alert endpoints share: from the turn's + * environment scope + a chat id to an authorized environment. + * + * Identical order of authority to `api.v1.dashboard-agent.watches.ts` — the + * environment comes from the token the dashboard minted for the current turn, the + * chat must be a live chat owned by that user in that environment's org, and the + * environment is re-authorized through the same path a background check uses. A + * client-supplied `environmentId`/`projectRef` is only ever checked against the + * token's scope, never used in its place, and the chat's stored context — a + * snapshot from whenever the chat started — is not consulted at all. + * + * Separate module rather than a helper on `dashboardAgentWatchAlerts.server.ts` so + * the alert service and the watches service don't import each other. + */ + +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { + authorizeWatchEnvironmentById, + resolveChatWatchContext, +} from "~/services/dashboardAgentWatches.server"; + +export type AgentAlertContextError = "chat_not_found" | "invalid_target" | "environment_mismatch"; + +export type AgentAlertContext = + | { ok: true; environment: AuthenticatedEnvironment } + | { ok: false; code: AgentAlertContextError; error: string }; + +export async function resolveAgentAlertContext(params: { + userId: string; + chatId: string; + /** The turn's environment scope, off the user-actor token. The authority here. */ + environmentId: string; + /** Optional echoes from the request body. Checked, never trusted. */ + claimedEnvironmentId?: string; + claimedProjectRef?: string; +}): Promise { + if (params.claimedEnvironmentId && params.claimedEnvironmentId !== params.environmentId) { + return { + ok: false, + code: "environment_mismatch", + error: "That environment isn't the one this chat is open in.", + }; + } + + const chat = await resolveChatWatchContext({ chatId: params.chatId, userId: params.userId }); + if (!chat) { + return { ok: false, code: "chat_not_found", error: "Chat not found" }; + } + + const environment = await authorizeWatchEnvironmentById({ + userId: params.userId, + environmentId: params.environmentId, + }); + if (!environment || environment.organizationId !== chat.organizationId) { + return { ok: false, code: "invalid_target", error: "Environment not found" }; + } + + if (params.claimedProjectRef && environment.project.externalRef !== params.claimedProjectRef) { + return { + ok: false, + code: "environment_mismatch", + error: "That project isn't the one this chat is open in.", + }; + } + + return { ok: true, environment }; +} diff --git a/apps/webapp/app/services/dashboardAgentAlertUnsubscribeToken.server.ts b/apps/webapp/app/services/dashboardAgentAlertUnsubscribeToken.server.ts new file mode 100644 index 00000000000..1973b8b93e8 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentAlertUnsubscribeToken.server.ts @@ -0,0 +1,77 @@ +/** + * Unsubscribe tokens — the credential in a watch alert email's "Turn off these + * alerts" link. + * + * Same construction as the watch token (`dashboardAgentWatchToken.server.ts`): + * HS256 over `SESSION_SECRET`, a disjoint routing prefix and a disjoint `kind` + * claim, so no other SESSION_SECRET-signed token is accepted here and this one is + * accepted nowhere else. + * + * It authorizes exactly one action — "stop sending watch alerts to this channel" — + * and names the channel and alert type it may act on. That's why it can be + * long-lived and needs no session: the worst a leaked link does is silence one + * alert type on one channel, which the recipient can re-enable on the Alerts page. + * + * Nothing is stored: the link is re-mintable from the channel id whenever an + * email is sent. + */ + +import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import { env } from "~/env.server"; + +const UNSUBSCRIBE_TOKEN_PREFIX = "tr_daau_"; +const UNSUBSCRIBE_TOKEN_KIND = "dashboard_agent_alert_unsubscribe"; +const UNSUBSCRIBE_PURPOSE = "unsubscribe"; + +/** Long-lived: an alert email has to keep working months after it arrived. */ +const UNSUBSCRIBE_TOKEN_TTL = "365d"; + +export type UnsubscribeTokenClaims = { channelId: string; alertType: string }; + +export async function signDashboardAgentAlertUnsubscribeToken( + secret: string, + opts: { channelId: string; alertType: string } +): Promise { + const jwt = await generateJWT({ + secretKey: secret, + payload: { + kind: UNSUBSCRIBE_TOKEN_KIND, + purpose: UNSUBSCRIBE_PURPOSE, + sub: opts.channelId, + alertType: opts.alertType, + }, + expirationTime: UNSUBSCRIBE_TOKEN_TTL, + }); + + return `${UNSUBSCRIBE_TOKEN_PREFIX}${jwt}`; +} + +export async function verifyDashboardAgentAlertUnsubscribeToken( + secret: string, + token: string +): Promise { + if (!token.startsWith(UNSUBSCRIBE_TOKEN_PREFIX)) return; + + const result = await validateJWT(token.slice(UNSUBSCRIBE_TOKEN_PREFIX.length), secret); + if (!result.ok) return; + + const payload = result.payload; + if (payload.kind !== UNSUBSCRIBE_TOKEN_KIND) return; + if (payload.purpose !== UNSUBSCRIBE_PURPOSE) return; + if (typeof payload.sub !== "string" || payload.sub.length === 0) return; + if (typeof payload.alertType !== "string" || payload.alertType.length === 0) return; + + return { channelId: payload.sub, alertType: payload.alertType }; +} + +/** Mint the token for a channel, using the platform secret. */ +export function mintDashboardAgentAlertUnsubscribeToken(opts: { + channelId: string; + alertType: string; +}): Promise { + return signDashboardAgentAlertUnsubscribeToken(env.SESSION_SECRET, opts); +} + +export function verifyUnsubscribeToken(token: string): Promise { + return verifyDashboardAgentAlertUnsubscribeToken(env.SESSION_SECRET, token); +} diff --git a/apps/webapp/app/services/dashboardAgentWatchAlerts.server.ts b/apps/webapp/app/services/dashboardAgentWatchAlerts.server.ts new file mode 100644 index 00000000000..aa234ff87f8 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatchAlerts.server.ts @@ -0,0 +1,256 @@ +/** + * Watch alerts — the seam between a watch firing and the standard alert pipeline. + * + * A fired watch already wakes its chat; this is the *other* delivery: the + * project's configured alert channels (email/Slack/webhook) that subscribe to + * `DASHBOARD_AGENT_WATCH`. Same channel model as run failures and deployments, so + * a user configures it once on the Alerts page and it works for every watch. + * + * Two things live here: the enqueue (called from every path that can fire a + * watch) and the gate both the fan-out and the agent's subscribe endpoint consult. + */ + +import { type Watch } from "@internal/dashboard-agent-db"; +import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { alertsWorker } from "~/v3/alertsWorker.server"; +import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server"; +import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server"; + +/** The alert type a watch fires under. */ +export const DASHBOARD_AGENT_WATCH_ALERT_TYPE = "DASHBOARD_AGENT_WATCH" as const; + +/** + * What the enqueue needs off a watch row. Declared as a shape rather than the + * full row so both the fired callback (which reads the row) and the + * fire-at-creation path (which has just written it) can hand one in. + */ +export type WatchFiredAlertSource = Pick< + Watch, + | "id" + | "identity" + | "spec" + | "organizationId" + | "projectId" + | "environmentId" + | "userId" + | "firedAt" + | "lastResult" + | "resolution" + | "observedOutcome" +>; + +/** + * Queue the alert fan-out for a watch that just resolved. + * + * Only `fired` dispatches. An expiry ("it didn't happen in 24h") is a chat-level + * non-event — the agent narrates it in the conversation, and mailing it would + * train people to ignore watch alerts. Kept in the signature so the callers read + * the same either way. + * + * The job id is the whole idempotency story: one fan-out job per watch, so the + * callback being retried, a tick redelivering, and the creation path racing the + * watcher all collapse into a single alert. The fan-out then enqueues one job per + * channel, itself keyed per channel. + */ +export async function enqueueWatchFiredAlert( + watch: WatchFiredAlertSource, + outcome: "fired" | "expired" +): Promise { + if (outcome !== "fired") return; + + await alertsWorker.enqueue({ + id: `watch-alert:${watch.id}`, + job: "v3.deliverDashboardAgentWatchAlert", + payload: { + watchId: watch.id, + organizationId: watch.organizationId, + projectId: watch.projectId, + environmentId: watch.environmentId, + userId: watch.userId, + identity: watch.identity, + kind: watch.spec.kind, + note: watch.spec.note, + firedAt: (watch.firedAt ?? new Date()).toISOString(), + facts: watch.lastResult ?? {}, + // The frozen resolved result — the email renders from these, never re-reading + // the source. A fired watch is condition_met by construction. + resolution: watch.resolution ?? "condition_met", + observed: watch.observedOutcome ?? undefined, + }, + }); +} + +// --------------------------------------------------------------------------- +// The gate. +// --------------------------------------------------------------------------- + +export type DashboardAgentAlertDenyReason = + /** The user can't use the dashboard agent, so its watches can't alert either. */ + | "dashboard_agent_disabled" + /** This installation has no alert email transport configured. */ + | "email_alerts_not_configured"; + +export type DashboardAgentAlertGate = + | { allowed: true } + | { allowed: false; reason: DashboardAgentAlertDenyReason }; + +/** + * May this user's watches alert at all? Operational checks only — the + * dashboard-agent feature flag here, the email transport below. + * + * No plan check: billing gates this separately, later. The `organizationId` stays + * in the signature so that gate can be re-attached here without touching callers. + */ +export async function canUseDashboardAgentAlerts(params: { + userId: string; + organizationSlug: string; + organizationId: string; + isAdmin?: boolean; + orgFeatureFlags?: Record | null; +}): Promise { + const hasAgent = await canAccessDashboardAgent({ + userId: params.userId, + isAdmin: params.isAdmin ?? false, + // Never an impersonated session: this runs in the background, or for the + // agent acting as the user. + isImpersonating: false, + organizationSlug: params.organizationSlug, + orgFeatureFlags: params.orgFeatureFlags, + }); + if (!hasAgent) return { allowed: false, reason: "dashboard_agent_disabled" }; + + return { allowed: true }; +} + +/** + * The same gate for *creating* an email subscription: the installation also needs + * an email transport, or the channel would be created and never deliver. + */ +export async function canUseDashboardAgentEmailAlerts( + params: Parameters[0] & { projectId: string } +): Promise { + const base = await canUseDashboardAgentAlerts(params); + if (!base.allowed) return base; + + // Mirrors what the alerts email client needs: a from-address and ANY + // configured transport (resend, smtp, aws-ses) — not resend specifically. + if (env.ALERT_FROM_EMAIL === undefined || env.ALERT_EMAIL_TRANSPORT === undefined) { + return { allowed: false, reason: "email_alerts_not_configured" }; + } + + return { allowed: true }; +} + +export type SubscribeToWatchAlertsResult = + | { ok: true; email: string } + | { ok: false; reason: DashboardAgentAlertDenyReason | "user_not_found" }; + +/** + * Subscribe the signed-in user's own account email to this project's watch + * alerts — the card's "Also notify me externally" opt-in (§6). + * + * Always the caller's OWN email, read off the primary: the address is never taken + * from the request, so this path cannot be used to mail a watch to someone else. + * The deduplication key is stable per (email, project), so opting in twice + * re-enables the existing subscription rather than stacking channels — and the + * subscription stays visible and cancellable on the Alerts page, which is where + * §6 says it must be managed. + * + * Advisory by design: the caller creates the watch first and treats a refusal + * here as "no external delivery", never as a failed watch. + */ +export async function subscribeUserToWatchAlerts(params: { + userId: string; + environment: { + type: string; + organizationId: string; + organization: { slug: string }; + project: { id: string; externalRef: string }; + }; +}): Promise { + const { userId, environment } = params; + + const gate = await canUseDashboardAgentEmailAlerts({ + userId, + organizationId: environment.organizationId, + organizationSlug: environment.organization.slug, + projectId: environment.project.id, + }); + if (!gate.allowed) return { ok: false, reason: gate.reason }; + + const user = await prisma.user.findFirst({ where: { id: userId }, select: { email: true } }); + if (!user) return { ok: false, reason: "user_not_found" }; + + const service = new CreateAlertChannelService(); + await service.call(environment.project.externalRef, userId, { + name: `Watch alerts for ${user.email}`, + alertTypes: [DASHBOARD_AGENT_WATCH_ALERT_TYPE], + environmentTypes: [environment.type as never], + deduplicationKey: `dashboard-agent-watch:${user.email}`, + channel: { type: "EMAIL", email: user.email }, + }); + + return { ok: true, email: user.email }; +} + +// --------------------------------------------------------------------------- +// Unsubscribe. +// --------------------------------------------------------------------------- + +export type UnsubscribeResult = + | { ok: true; channelName: string; disabledChannel: boolean } + | { ok: false; reason: "not_found" | "conflict" }; + +/** How many times a lost race is retried before the caller is told to try again. */ +const UNSUBSCRIBE_ATTEMPTS = 3; + +/** + * Take `DASHBOARD_AGENT_WATCH` off a channel — what both the email's one-click + * link and the agent's DELETE endpoint do. + * + * A channel that subscribed to nothing else is disabled rather than left with an + * empty `alertTypes`: an empty subscription list is a channel that silently + * receives nothing, which reads as a bug on the Alerts page. + * + * Removing one entry from a list can't be expressed as a blind update, so the + * write is conditional on the list the read saw. Anyone editing the channel's + * other subscriptions concurrently makes this attempt fail rather than clobber + * them, and it retries against the new list. + */ +export async function unsubscribeChannelFromWatchAlerts( + channelId: string, + options: { projectId?: string } = {}, + db: PrismaClientOrTransaction = prisma +): Promise { + const scope = { id: channelId, ...(options.projectId ? { projectId: options.projectId } : {}) }; + + for (let attempt = 0; attempt < UNSUBSCRIBE_ATTEMPTS; attempt++) { + const channel = await db.projectAlertChannel.findFirst({ + where: scope, + select: { name: true, alertTypes: true }, + }); + if (!channel) return { ok: false, reason: "not_found" }; + + const remaining = channel.alertTypes.filter( + (type) => type !== DASHBOARD_AGENT_WATCH_ALERT_TYPE + ); + + const { count } = await db.projectAlertChannel.updateMany({ + // `alertTypes` here is the compare-and-swap: the row must still hold the + // exact list this attempt read. + where: { ...scope, alertTypes: { equals: channel.alertTypes } }, + data: { + alertTypes: remaining, + ...(remaining.length === 0 ? { enabled: false } : {}), + }, + }); + + if (count > 0) { + return { ok: true, channelName: channel.name, disabledChannel: remaining.length === 0 }; + } + } + + return { ok: false, reason: "conflict" }; +} diff --git a/apps/webapp/app/services/dashboardAgentWatchChecks.server.ts b/apps/webapp/app/services/dashboardAgentWatchChecks.server.ts new file mode 100644 index 00000000000..5c3a40f44e1 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatchChecks.server.ts @@ -0,0 +1,305 @@ +/** + * Default IO wiring for the watch checks — the ONLY place this feature touches a + * datastore. Kept apart from `dashboardAgentWatchChecks.ts` so the checks stay + * transport- and IO-independent (tests inject fake readers, never mocks). + * + * ClickHouse-first, with Postgres reserved for authoritative point-reads: + * - run state: ONE Postgres run row (by friendlyId + environment). Run state is + * transactional and must not be read from an analytics rollup. + * - queue depth: the LIVE run-queue counter (`engine.lengthOfQueue`) first — the + * same seam the queue pages and the waiting-run module use — with the + * ClickHouse depth series as fallback. + * - queue existence: ONE Postgres `TaskQueue` point-read. + * - error recurrence: ClickHouse `errors_v1` for WHETHER it recurred (millisecond + * `last_seen`), plus the per-minute `error_occurrences_v1` rollup for the count. + * - health: the existing health report (loader + interpreter), unchanged. + * + * Readers THROW on failure rather than swallowing it, because `checkWatch` turns a + * throw into `unavailable`. A reader that returned a made-up zero would fire a + * watch on a broken data source. + */ + +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { $replica } from "~/db.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { ReportPresenter } from "~/presenters/v3/reports/ReportPresenter.server"; +import { engine } from "~/v3/runEngine.server"; +import { runStore } from "~/v3/runStore.server"; +import type { + WatchCheckDeps, + WatchErrorRecurrence, + WatchHealthSeverity, + WatchHealthSnapshot, + WatchQueueDepth, + WatchRunRow, +} from "./dashboardAgentWatchChecks"; + +const WATCH_RUN_SELECT = { + friendlyId: true, + status: true, + queue: true, + createdAt: true, + queuedAt: true, + startedAt: true, + completedAt: true, + delayUntil: true, +} as const; + +/** The single Postgres point-read: one run, scoped to the watch's environment. */ +export async function readWatchRun( + runFriendlyId: string, + environmentId: string +): Promise { + const run = await runStore.findRun( + { friendlyId: runFriendlyId, runtimeEnvironmentId: environmentId }, + { select: WATCH_RUN_SELECT }, + $replica + ); + return run ?? null; +} + +/** One Postgres point-read: does this queue exist in the environment? */ +export async function watchQueueExists(environmentId: string, queueName: string): Promise { + const queue = await $replica.taskQueue.findFirst({ + where: { runtimeEnvironmentId: environmentId, name: queueName }, + select: { id: true }, + }); + return queue !== null; +} + +/** How far back the ClickHouse depth fallback looks when the live counter is down. */ +const DEPTH_FALLBACK_MINUTES = 10; +const DEPTH_FALLBACK_BUCKET_SECONDS = 60; +/** + * How far behind `now` the newest analytics bucket may END and still be read as + * "the queue right now". One bucket of slack: anything older leaves a gap the + * rollup hasn't covered, and runs queued in that gap would be invisible. + */ +const DEPTH_FRESH_TOLERANCE_MS = DEPTH_FALLBACK_BUCKET_SECONDS * 1000; + +function formatClickhouseDateTime(date: Date): string { + return date.toISOString().slice(0, 19).replace("T", " "); +} + +/** ClickHouse renders DateTime without a zone; the column is UTC. */ +function parseClickhouseDateTime(value: string): Date { + return new Date(`${value.replace(" ", "T")}Z`); +} + +/** + * Current pending count for one queue. The live run-queue counter is the truth + * ("is it drained RIGHT NOW"); ClickHouse is the fallback and reports the most + * recent bucket's PEAK depth, which can only over-report within that bucket. + * + * The fallback carries `current`, and it is false unless the newest bucket + * actually reaches the present: a rollup that's minutes behind may hold an empty + * bucket while runs piled up after it, and reading that as "drained" is the one + * mistake this watch must never make. `checkBacklogDrain` turns a stale zero into + * `unavailable`. + */ +export async function readWatchQueueDepth( + environment: AuthenticatedEnvironment, + queueName: string, + now: Date = new Date() +): Promise { + const live = await engine.lengthOfQueue(environment, queueName).catch(() => null); + if (typeof live === "number" && Number.isFinite(live)) { + return { depth: live, source: "live_queue", current: true, asOf: now }; + } + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + environment.organizationId, + "query" + ); + + const bucketMs = DEPTH_FALLBACK_BUCKET_SECONDS * 1000; + const endMs = Math.ceil(now.getTime() / bucketMs) * bucketMs; + const startMs = endMs - DEPTH_FALLBACK_MINUTES * 60_000; + + const [error, rows] = await clickhouse.queueMetrics.depthSparklines({ + organizationId: environment.organizationId, + projectId: environment.projectId, + environmentId: environment.id, + queueNames: [queueName], + startTime: formatClickhouseDateTime(new Date(startMs)), + endTime: formatClickhouseDateTime(new Date(endMs)), + bucketSeconds: DEPTH_FALLBACK_BUCKET_SECONDS, + }); + + if (error) throw error; + if (!rows || rows.length === 0) return null; + + // Newest bucket wins — the closest thing the rollup has to "now". + const newest = rows.reduce((best, row) => (row.bucket > best.bucket ? row : best), rows[0]!); + const bucketEnd = new Date(parseClickhouseDateTime(newest.bucket).getTime() + bucketMs); + const current = bucketEnd.getTime() >= now.getTime() - DEPTH_FRESH_TOLERANCE_MS; + + return { depth: newest.depth, source: "queue_metrics", current, asOf: bucketEnd }; +} + +const MINUTE_MS = 60_000; + +type OrganizationClickhouse = Awaited< + ReturnType +>; + +/** + * The fingerprint's most recent occurrence, at MILLISECOND precision, from the + * `errors_v1` aggregate (`max(last_seen)` over every task that produced it). + * + * This is what makes "has it come back?" answerable exactly. The per-minute + * `error_occurrences_v1` rollup can only place an error in a minute, and the + * minute a watch is created in holds BOTH the error that prompted the watch and + * any recurrence seconds later — so the rollup alone can neither confirm nor deny + * a recurrence in that first minute. + */ +async function readErrorLastSeen( + clickhouse: OrganizationClickhouse, + environment: AuthenticatedEnvironment, + fingerprint: string +): Promise { + const builder = clickhouse.errors.activeErrorsSinceQueryBuilder(); + builder.where("organization_id = {organizationId: String}", { + organizationId: environment.organizationId, + }); + builder.where("project_id = {projectId: String}", { projectId: environment.projectId }); + builder.where("environment_id = {environmentId: String}", { environmentId: environment.id }); + builder.where("error_fingerprint = {fingerprint: String}", { fingerprint }); + builder.groupBy("environment_id, task_identifier, error_fingerprint"); + + const [error, rows] = await builder.execute(); + if (error) throw error; + if (!rows || rows.length === 0) return null; + + let lastSeenMs = 0; + for (const row of rows) { + const ms = Number(row.last_seen); + if (Number.isFinite(ms) && ms > lastSeenMs) lastSeenMs = ms; + } + + return lastSeenMs > 0 ? new Date(lastSeenMs) : null; +} + +/** + * What we know about an error fingerprint relative to `since`, from two reads that + * each answer what only they can: + * + * - `errors_v1` decides WHETHER it recurred, to the millisecond. An occurrence + * 40 seconds after the watch was created is a recurrence, and rounding the + * window up to the next minute used to lose it entirely. + * - `error_occurrences_v1` supplies HOW MANY and, for minutes after the creation + * minute, when. Its creation-minute bucket can't be split between the original + * error and a recurrence, so those occurrences only make `countSince` a lower + * bound (`countApproximate`) — never a claim. + */ +export async function readWatchErrorRecurrence( + environment: AuthenticatedEnvironment, + fingerprint: string, + since: Date +): Promise { + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + environment.organizationId, + "logs" + ); + + const lastSeenAt = await readErrorLastSeen(clickhouse, environment, fingerprint); + // Never seen in this environment at all. + if (!lastSeenAt) return null; + + const notRecurred: WatchErrorRecurrence = { + occurredAt: null, + occurredAtPrecision: null, + countSince: 0, + countApproximate: false, + lastSeenAt, + }; + if (lastSeenAt.getTime() <= since.getTime()) return notRecurred; + + // Something landed after `since`. The rollup fills in the count and the minute. + const sinceMinuteMs = Math.floor(since.getTime() / MINUTE_MS) * MINUTE_MS; + const queryBuilder = clickhouse.errors.createOccurrencesQueryBuilder("INTERVAL 1 MINUTE"); + queryBuilder.where("organization_id = {organizationId: String}", { + organizationId: environment.organizationId, + }); + queryBuilder.where("project_id = {projectId: String}", { projectId: environment.projectId }); + queryBuilder.where("environment_id = {environmentId: String}", { environmentId: environment.id }); + queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint }); + // The creation minute is INCLUDED — its occurrences are what the old + // `minute > since` filter dropped. + queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({sinceMs: Int64}))", { + sinceMs: since.getTime(), + }); + queryBuilder.groupBy("error_fingerprint, bucket_epoch"); + queryBuilder.orderBy("bucket_epoch ASC"); + + const [error, rows] = await queryBuilder.execute(); + if (error) throw error; + + let earliestAfterMs: number | null = null; + let countAfter = 0; + let creationMinuteCount = 0; + + for (const row of rows ?? []) { + const bucketMs = row.bucket_epoch * 1000; + if (bucketMs <= sinceMinuteMs) { + creationMinuteCount += row.count; + continue; + } + countAfter += row.count; + if (earliestAfterMs === null || bucketMs < earliestAfterMs) earliestAfterMs = bucketMs; + } + + // The earliest time we can PROVE an occurrence at: a bucket that starts after + // the creation minute, or — when the only evidence is in that minute, or the + // rollup hasn't caught up — the exact `last_seen`. + const useBucket = earliestAfterMs !== null && earliestAfterMs < lastSeenAt.getTime(); + + return { + occurredAt: useBucket ? new Date(earliestAfterMs!) : lastSeenAt, + occurredAtPrecision: useBucket ? "minute" : "exact", + // At least the one `errors_v1` proved, even if the rollup lags behind it. + countSince: Math.max(1, countAfter), + countApproximate: creationMinuteCount > 0, + lastSeenAt, + }; +} + +const HEALTH_SEVERITIES = new Set(["ok", "warn", "crit"]); + +/** + * The health report's current verdict, straight from the existing interpreter — + * the same `summary.severity` the dashboard and `get_report` show, and the same + * `facts.trustworthy` trust marker. No health reasoning is re-implemented here. + */ +export async function readWatchHealth( + environment: AuthenticatedEnvironment +): Promise { + const report = await new ReportPresenter().call({ environment, key: "health" }); + if (!report) return null; + + const severity = report.summary.severity; + if (!HEALTH_SEVERITIES.has(severity)) return null; + + const trustworthy = (report.facts as { trustworthy?: unknown } | undefined)?.trustworthy; + return { + // Absent trust marker is treated as untrustworthy: recovery must never fire + // off a report that didn't state it was trustworthy. + trustworthy: trustworthy === true, + severity: severity as WatchHealthSeverity, + }; +} + +/** Wire the real readers for one environment. */ +export function watchCheckDeps( + environment: AuthenticatedEnvironment, + now: Date = new Date() +): WatchCheckDeps { + return { + readRun: (runId) => readWatchRun(runId, environment.id), + queueExists: (queue) => watchQueueExists(environment.id, queue), + readQueueDepth: (queue) => readWatchQueueDepth(environment, queue, now), + readErrorRecurrence: (fingerprint, since) => + readWatchErrorRecurrence(environment, fingerprint, since), + readHealth: () => readWatchHealth(environment), + }; +} diff --git a/apps/webapp/app/services/dashboardAgentWatchChecks.ts b/apps/webapp/app/services/dashboardAgentWatchChecks.ts new file mode 100644 index 00000000000..7917a7ecae7 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatchChecks.ts @@ -0,0 +1,711 @@ +/** + * Watch checks — the DETERMINISTIC evaluation of one watch condition. No LLM + * anywhere in this file, and no transport: IO lives behind `WatchCheckDeps` so + * tests inject plain fake readers (same shape as `waitingRunDiagnosis.ts`). + * + * Every check answers with one of four values (see the contract in + * `@internal/dashboard-agent-contracts`): + * pending · satisfied · terminal_unsatisfied · unavailable + * + * Two rules hold for all of them: + * + * 1. **`unavailable` is never a verdict.** A reader that throws, or data that + * can't be read, yields `unavailable` — never `pending` (which would quietly + * burn the watch's lifetime on a broken data source) and never `satisfied`. + * 2. **`facts` are the numbers the wake narration reads.** They are computed + * here, deterministically, so the model never has to derive a duration or a + * depth itself. Durations carry their BASIS (VERDICTS.md §4): a wait is only + * labelled a queue wait when `queuedAt` exists. + * 3. **`observed` is what the check SAW**, in the contracts' per-kind shape. It is + * the second half of a resolved result: the resolution says how the watch + * ended, the observation says what was true when it did — and the presentation + * needs both (§4.2). `run_finished` is the clearest case: the reader preserves + * the final status, so a completion WITH FAILURE presents as attention rather + * than as the success its `condition_met` resolution would otherwise imply. + */ + +import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; +import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations"; +import { + watchRunDisposition, + type WatchCheckResult, + type WatchObservedOutcome, + type WatchSpec, +} from "@internal/dashboard-agent-contracts"; + +// --------------------------------------------------------------------------- +// Inputs — plain data, no Prisma / ClickHouse types. +// --------------------------------------------------------------------------- + +/** The single run point-read. Postgres is authoritative for run state. */ +export type WatchRunRow = { + friendlyId: string; + status: string; + queue: string; + createdAt: Date; + /** Stamped when the run entered the queue. NULL while a run is delayed. */ + queuedAt: Date | null; + /** Set once the run is dequeued — the run has started. */ + startedAt: Date | null; + completedAt: Date | null; + delayUntil: Date | null; +}; + +export type WatchQueueDepth = { + /** Pending count for the queue, as of `asOf`. */ + depth: number; + source: "live_queue" | "queue_metrics"; + /** + * Whether the reading describes the queue RIGHT NOW. A live counter always + * does; an analytics bucket only does while it's fresh enough to cover the + * present. A stale reading can never answer "drained" — see + * `checkBacklogDrain`. + */ + current: boolean; + /** What instant the reading describes, when it isn't the live counter. */ + asOf?: Date; +}; + +/** What we know about the watched error's occurrences relative to `since`. */ +export type WatchErrorRecurrence = { + /** + * The earliest occurrence PROVEN to be after `since`, or null when nothing has + * recurred. Null with a `lastSeenAt` means "seen before, not since". + */ + occurredAt: Date | null; + /** How precisely `occurredAt` is known: to the millisecond, or to its minute. */ + occurredAtPrecision: "exact" | "minute" | null; + /** Occurrences after `since`. A LOWER BOUND when `countApproximate`. */ + countSince: number; + /** + * True when `countSince` can't be split exactly — occurrences in the minute + * the watch was created can't be told apart from the error that prompted it. + */ + countApproximate: boolean; + /** The fingerprint's most recent occurrence, whenever it was. */ + lastSeenAt: Date | null; +}; + +export type WatchHealthSeverity = "ok" | "warn" | "crit"; + +export type WatchHealthSnapshot = { + /** `facts.trustworthy` from the health report. Untrustworthy NEVER fires recovery. */ + trustworthy: boolean; + severity: WatchHealthSeverity; +}; + +/** + * The readers a check may use. Each may throw — the caller turns that into + * `unavailable`. Returning `null` means "the data source answered, and there is + * nothing there", which each check interprets on its own terms. + */ +export type WatchCheckDeps = { + /** Run point-read by public run id, scoped to the watch's environment. */ + readRun: (runId: string) => Promise; + /** Does this queue exist in the watch's environment? */ + queueExists: (queue: string) => Promise; + /** Current pending count, live run-queue first with a ClickHouse fallback. */ + readQueueDepth: (queue: string) => Promise; + /** + * What's known about `fingerprint` relative to `since`. `null` means the + * fingerprint has no occurrences at all in this environment. + */ + readErrorRecurrence: (fingerprint: string, since: Date) => Promise; + /** The health report's current verdict for the watch's environment. */ + readHealth: () => Promise; +}; + +export type WatchCheckInput = { + /** Evaluation clock — injected so durations are testable. */ + now: Date; + /** + * The recurrence window's start for `error_recurrence`: the server-set + * `spec.since`, falling back to the watch row's `createdAt`. Never caller-set. + */ + since: Date; +}; + +export type WatchCheckOutcome = { + result: WatchCheckResult; + facts: Record; + /** + * What this check observed, in the contracts' per-kind shape. Frozen onto the + * row by the resolving transition, so every delivery surface reads the same + * observation and none of them re-reads the source (§7.5). + */ + observed: WatchObservedOutcome; +}; + +// --------------------------------------------------------------------------- +// Run status vocabulary (mirrors ~/v3/taskStatus, kept local so this module has +// no server-side import and stays trivially testable). +// --------------------------------------------------------------------------- + +const FINAL_STATUSES = new Set([ + "CANCELED", + "INTERRUPTED", + "COMPLETED_SUCCESSFULLY", + "COMPLETED_WITH_ERRORS", + "SYSTEM_FAILURE", + "CRASHED", + "EXPIRED", + "TIMED_OUT", +]); + +/** + * Statuses whose `queuedAt` is a stale leftover from the FIRST enqueue + * (resume/retry re-enqueues don't restamp it), so a wait computed from it isn't + * this attempt's queue wait — VERDICTS.md §4. + */ +const STALE_QUEUED_AT_STATUSES = new Set(["WAITING_TO_RESUME", "RETRYING_AFTER_FAILURE", "PAUSED"]); + +export function isTerminalRunStatus(status: string): boolean { + return FINAL_STATUSES.has(status); +} + +function formatMs(ms: number): string { + return formatDurationMilliseconds(ms, { style: "short", maxDecimalPoints: 0 }); +} + +/** Which timestamp a wait was measured from — the label's honesty guarantee. */ +export type WatchWaitBasis = "queued_at" | "delay_until" | "created_at"; + +/** + * The wait a run has accumulated, with the ONLY label the data supports: a + * `queuedAt` that belongs to THIS attempt -> a real queue wait; a future + * `delayUntil` -> a schedule, not latency; otherwise time from creation, said out + * loud. + * + * A resumed/retried/paused run's `queuedAt` is a leftover from the first enqueue, + * so it is not measured from at all: a number that isn't this attempt's queue + * wait must never be worded as one, even with a flag next to it. + */ +export function describeRunWait( + run: WatchRunRow, + now: Date +): { + waitMs: number | null; + waitBasis: WatchWaitBasis; + waitLabel: string; + /** True only when the wait IS this attempt's queue wait. */ + queueWaitReliable: boolean; +} { + const queueWaitReliable = run.queuedAt !== null && !STALE_QUEUED_AT_STATUSES.has(run.status); + const end = run.startedAt ?? now; + + if (run.queuedAt && queueWaitReliable) { + const waitMs = Math.max(0, end.getTime() - run.queuedAt.getTime()); + return { + waitMs, + waitBasis: "queued_at", + waitLabel: `queued for ${formatMs(waitMs)}`, + queueWaitReliable, + }; + } + + if (run.delayUntil && run.delayUntil.getTime() > now.getTime()) { + return { + waitMs: null, + waitBasis: "delay_until", + waitLabel: `scheduled to start at ${run.delayUntil.toISOString()}`, + queueWaitReliable, + }; + } + + // Either there is no `queuedAt`, or the one we have belongs to an earlier + // attempt. Both fall back to the run's age, and say that's what it is. + const waitMs = Math.max(0, end.getTime() - run.createdAt.getTime()); + const resumeOrRetry = run.queuedAt !== null; + return { + waitMs, + waitBasis: "created_at", + waitLabel: resumeOrRetry + ? `waiting to ${run.status === "RETRYING_AFTER_FAILURE" ? "retry" : "resume"}; time from creation: ${formatMs(waitMs)}` + : `time from creation: ${formatMs(waitMs)}`, + queueWaitReliable, + }; +} + +// --------------------------------------------------------------------------- +// One function per WatchSpec kind. +// --------------------------------------------------------------------------- + +/** + * run_start — satisfied the moment `startedAt` exists, whatever the run's CURRENT + * status is: a run that started and then failed still started, and the user asked + * about the start. Terminal with no `startedAt` (cancelled/expired while queued) + * can never start, so it's `terminal_unsatisfied`. + */ +export async function checkRunStart( + spec: Extract, + deps: WatchCheckDeps, + input: WatchCheckInput +): Promise { + const run = await deps.readRun(spec.runId); + if (!run) { + // Existence was validated when the watch was created, so absence now means + // the run is gone from this environment — it can never start. + return { + result: "terminal_unsatisfied", + facts: { runId: spec.runId, reason: "run_not_found" }, + observed: { kind: "run_start", verified: true, status: null, started: false }, + }; + } + + const wait = describeRunWait(run, input.now); + const facts = { + runId: run.friendlyId, + status: run.status, + queue: run.queue, + startedAt: run.startedAt?.toISOString() ?? null, + queuedAt: run.queuedAt?.toISOString() ?? null, + ...wait, + }; + const observed: WatchObservedOutcome = { + kind: "run_start", + verified: true, + status: run.status, + started: run.startedAt !== null, + }; + + if (run.startedAt) return { result: "satisfied", facts, observed }; + if (isTerminalRunStatus(run.status)) { + return { + result: "terminal_unsatisfied", + facts: { ...facts, reason: "never_started" }, + observed, + }; + } + return { result: "pending", facts, observed }; +} + +/** + * run_finished — satisfied on ANY terminal status, and the observation PRESERVES + * that status. The resolution alone cannot tell "Run abc123 finished" from "Run + * abc123 failed": both are `condition_met`, and only `observed.finalStatus` + * separates them (§4.2, §7.4). The reader never filters on status — a watch on a + * run that fails is still answered, just presented as attention. + */ +export async function checkRunFinished( + spec: Extract, + deps: WatchCheckDeps, + input: WatchCheckInput +): Promise { + const run = await deps.readRun(spec.runId); + if (!run) { + return { + result: "terminal_unsatisfied", + facts: { runId: spec.runId, reason: "run_not_found" }, + observed: { kind: "run_finished", verified: true, finalStatus: null, durationMs: null }, + }; + } + + const finished = isTerminalRunStatus(run.status); + // Execution duration only — startedAt -> completedAt. A run that never started + // has no duration, and the queue wait is reported separately. + const durationMs = + run.startedAt && run.completedAt + ? Math.max(0, run.completedAt.getTime() - run.startedAt.getTime()) + : null; + + const wait = describeRunWait(run, input.now); + const facts = { + runId: run.friendlyId, + outcome: run.status, + startedAt: run.startedAt?.toISOString() ?? null, + completedAt: run.completedAt?.toISOString() ?? null, + durationMs, + durationLabel: durationMs === null ? null : formatMs(durationMs), + ...wait, + }; + + return { + result: finished ? "satisfied" : "pending", + facts, + observed: { + kind: "run_finished", + verified: true, + // Only a terminal status is a FINAL status. A running run has no verdict yet. + finalStatus: finished ? run.status : null, + durationMs, + }, + }; +} + +/** + * run_failed — the same point read as `run_finished`, asked the other way round. + * + * The asymmetry is the whole point: a FAILING terminal status satisfies it, while + * a run that completes successfully makes the condition impossible rather than + * merely unmet — it can never fail now, and that is the good news the mapping + * presents (§4.2). A cancellation is neither, so it is also terminal: the run will + * not fail, and the presentation says it was cancelled rather than claiming a win. + */ +export async function checkRunFailed( + spec: Extract, + deps: WatchCheckDeps, + input: WatchCheckInput +): Promise { + const run = await deps.readRun(spec.runId); + if (!run) { + return { + result: "terminal_unsatisfied", + facts: { runId: spec.runId, reason: "run_not_found" }, + observed: { kind: "run_failed", verified: true, finalStatus: null, durationMs: null }, + }; + } + + const finished = isTerminalRunStatus(run.status); + const durationMs = + run.startedAt && run.completedAt + ? Math.max(0, run.completedAt.getTime() - run.startedAt.getTime()) + : null; + + const wait = describeRunWait(run, input.now); + const facts = { + runId: run.friendlyId, + outcome: run.status, + startedAt: run.startedAt?.toISOString() ?? null, + completedAt: run.completedAt?.toISOString() ?? null, + durationMs, + durationLabel: durationMs === null ? null : formatMs(durationMs), + ...wait, + }; + const observed: WatchObservedOutcome = { + kind: "run_failed", + verified: true, + finalStatus: finished ? run.status : null, + durationMs, + }; + + if (!finished) return { result: "pending", facts, observed }; + + return { + result: watchRunDisposition(run.status) === "failed" ? "satisfied" : "terminal_unsatisfied", + facts: { + ...facts, + ...(watchRunDisposition(run.status) === "failed" ? {} : { reason: "cannot_fail_now" }), + }, + observed, + }; +} + +/** + * The queue-depth read both threshold kinds share, resolved down to either a + * usable reading or the outcome that replaces it. + * + * A queue that no longer exists can never be observed, so that is + * `terminal_unsatisfied`; a depth we can't read is `unavailable`, never a number. + * The freshness fence lives here too: a stale analytics bucket says nothing about + * the runs queued after it, so a zero from one is `unavailable`. A stale NON-zero + * depth is still worth reporting — the queue demonstrably wasn't empty — and is + * marked approximate. + */ +async function readDepthOrOutcome( + queue: string, + deps: WatchCheckDeps, + observedKind: "backlog_drain" | "queue_depth_above", + threshold: number +): Promise< + | { ok: true; depth: WatchQueueDepth; facts: Record } + | { ok: false; outcome: WatchCheckOutcome } +> { + const unobserved = (verified: boolean): WatchObservedOutcome => + observedKind === "queue_depth_above" + ? { kind: "queue_depth_above", verified, depth: null, threshold } + : { kind: "backlog_drain", verified, depth: null }; + + const depth = await deps.readQueueDepth(queue); + + if (depth === null) { + // Distinguish "no such queue" from "couldn't read the depth" — only the + // former is terminal. + const exists = await deps.queueExists(queue); + if (!exists) { + return { + ok: false, + outcome: { + result: "terminal_unsatisfied", + facts: { queue, reason: "queue_not_found" }, + observed: unobserved(true), + }, + }; + } + return { + ok: false, + outcome: { + result: "unavailable", + facts: { queue, reason: "depth_unavailable" }, + observed: unobserved(false), + }, + }; + } + + const facts = { + queue, + depth: depth.depth, + depthSource: depth.source, + depthAsOf: depth.asOf?.toISOString() ?? null, + depthApproximate: !depth.current, + }; + + // A zero needs a reading that describes NOW. Reading a stale empty bucket as + // "drained" (or as "below the threshold") is the one mistake these must never + // make. + if (depth.depth === 0 && !depth.current) { + return { + ok: false, + outcome: { + result: "unavailable", + facts: { ...facts, reason: "depth_stale" }, + observed: unobserved(false), + }, + }; + } + + return { ok: true, depth, facts }; +} + +/** + * backlog_drain — satisfied when the queue's current pending count is 0. + * + * The observation carries the depth the resolving check read, so a window that + * completes without a drain can say HOW backed up the queue still was without + * going back to the source. + */ +export async function checkBacklogDrain( + spec: Extract, + deps: WatchCheckDeps, + _input: WatchCheckInput +): Promise { + const read = await readDepthOrOutcome(spec.queue, deps, "backlog_drain", 0); + if (!read.ok) return read.outcome; + + return { + result: read.depth.depth === 0 ? "satisfied" : "pending", + facts: read.facts, + observed: { kind: "backlog_drain", verified: true, depth: read.depth.depth }, + }; +} + +/** + * queue_depth_above — the SAME depth reader with the comparison inverted: + * satisfied when the pending count rises ABOVE `threshold`. No new IO, and the + * freshness fence is shared verbatim, so a stale bucket can no more prove "still + * below" than it can prove "drained". + * + * Deliberately no `terminal_unsatisfied` on a live queue: a queue that is quiet + * now can grow at any moment, which is exactly what this watch is for. Only the + * queue disappearing makes the condition impossible. + */ +export async function checkQueueDepthAbove( + spec: Extract, + deps: WatchCheckDeps, + _input: WatchCheckInput +): Promise { + const read = await readDepthOrOutcome(spec.queue, deps, "queue_depth_above", spec.threshold); + if (!read.ok) return read.outcome; + + return { + result: read.depth.depth > spec.threshold ? "satisfied" : "pending", + facts: { ...read.facts, threshold: spec.threshold }, + observed: { + kind: "queue_depth_above", + verified: true, + depth: read.depth.depth, + threshold: spec.threshold, + }, + }; +} + +/** + * The model cites the API error id (`error_`), but ClickHouse stores + * the raw fingerprint — same normalization the errors API route uses. Raw + * fingerprints pass through unchanged. + */ +export function normalizeErrorFingerprint(fingerprint: string): string { + return ErrorId.toId(fingerprint); +} + +/** + * error_recurrence — satisfied on the first occurrence proven to be after the + * server-set `since`. `since` is never caller-set, so the model can't backdate the + * window and make a pre-existing error look like a recurrence. + * + * The facts carry the PRECISION of what they claim (`occurredAtPrecision`, + * `countApproximate`) and, when nothing recurred, when the error was last seen — + * so the wake narration can't assert more than the data supports. + */ +export async function checkErrorRecurrence( + spec: Extract, + deps: WatchCheckDeps, + input: WatchCheckInput +): Promise { + const fingerprint = normalizeErrorFingerprint(spec.fingerprint); + const recurrence = await deps.readErrorRecurrence(fingerprint, input.since); + const base = { fingerprint, since: input.since.toISOString() }; + + const quiet: WatchObservedOutcome = { + kind: "error_recurrence", + verified: true, + countSince: 0, + }; + + if (!recurrence) { + return { + result: "pending", + facts: { ...base, countSince: 0, lastSeenAt: null }, + observed: quiet, + }; + } + + const lastSeenAt = recurrence.lastSeenAt?.toISOString() ?? null; + + if (!recurrence.occurredAt) { + return { + result: "pending", + facts: { ...base, countSince: 0, lastSeenAt }, + observed: quiet, + }; + } + + return { + result: "satisfied", + facts: { + ...base, + occurredAt: recurrence.occurredAt.toISOString(), + occurredAtPrecision: recurrence.occurredAtPrecision, + countSince: recurrence.countSince, + countApproximate: recurrence.countApproximate, + lastSeenAt, + }, + observed: { + kind: "error_recurrence", + verified: true, + countSince: recurrence.countSince, + }, + }; +} + +/** + * health_recovery — satisfied only when the health report is BOTH trustworthy and + * `ok`. Stale telemetry marks the report untrustworthy, and an untrustworthy + * report can never fire a recovery: "looks fine" off stale data is exactly the + * false all-clear this watch exists to avoid. + */ +export async function checkHealthRecovery( + spec: Extract, + deps: WatchCheckDeps, + _input: WatchCheckInput +): Promise { + const health = await deps.readHealth(); + if (!health) { + return { + result: "unavailable", + facts: { report: spec.report, reason: "report_unavailable" }, + observed: { kind: "health_recovery", verified: false, severity: null }, + }; + } + + const facts = { + report: spec.report, + fromSeverity: spec.fromSeverity, + severity: health.severity, + trustworthy: health.trustworthy, + }; + + if (!health.trustworthy) { + // An untrustworthy report is not an observation of the severity: recording it + // would let a window completion cite a severity nobody could stand behind. + return { + result: "pending", + facts: { ...facts, reason: "untrustworthy" }, + observed: { kind: "health_recovery", verified: false, severity: null }, + }; + } + + return { + result: health.severity === "ok" ? "satisfied" : "pending", + facts, + observed: { kind: "health_recovery", verified: true, severity: health.severity }, + }; +} + +// --------------------------------------------------------------------------- +// Entry point. +// --------------------------------------------------------------------------- + +/** + * Evaluate one watch. The single place a check failure becomes `unavailable`: + * every reader may throw and nothing here turns a broken data source into a + * verdict. + */ +export async function checkWatch( + spec: WatchSpec, + deps: WatchCheckDeps, + input: WatchCheckInput, + onError?: (error: unknown) => void +): Promise { + try { + switch (spec.kind) { + case "run_start": + return await checkRunStart(spec, deps, input); + case "run_finished": + return await checkRunFinished(spec, deps, input); + case "run_failed": + return await checkRunFailed(spec, deps, input); + case "backlog_drain": + return await checkBacklogDrain(spec, deps, input); + case "queue_depth_above": + return await checkQueueDepthAbove(spec, deps, input); + case "error_recurrence": + return await checkErrorRecurrence(spec, deps, input); + case "health_recovery": + return await checkHealthRecovery(spec, deps, input); + default: { + const unreachable: never = spec; + throw new Error(`Unhandled watch kind: ${JSON.stringify(unreachable)}`); + } + } + } catch (error) { + onError?.(error); + return { + result: "unavailable", + facts: { kind: spec.kind, reason: "check_failed" }, + observed: unobservedOutcome(spec), + }; + } +} + +/** + * The "we saw nothing" observation for a check that couldn't run. `verified: + * false` is what makes a window completion say the condition couldn't be + * confirmed, instead of claiming it didn't happen (§4.2). + */ +export function unobservedOutcome(spec: WatchSpec): WatchObservedOutcome { + switch (spec.kind) { + case "run_start": + return { kind: "run_start", verified: false, status: null, started: false }; + case "run_finished": + return { kind: "run_finished", verified: false, finalStatus: null, durationMs: null }; + case "run_failed": + return { kind: "run_failed", verified: false, finalStatus: null, durationMs: null }; + case "backlog_drain": + return { kind: "backlog_drain", verified: false, depth: null }; + case "queue_depth_above": + return { + kind: "queue_depth_above", + verified: false, + depth: null, + threshold: spec.threshold, + }; + case "error_recurrence": + return { kind: "error_recurrence", verified: false, countSince: 0 }; + case "health_recovery": + return { kind: "health_recovery", verified: false, severity: null }; + default: { + const unreachable: never = spec; + throw new Error(`Unhandled watch kind: ${JSON.stringify(unreachable)}`); + } + } +} diff --git a/apps/webapp/app/services/dashboardAgentWatchSweep.server.ts b/apps/webapp/app/services/dashboardAgentWatchSweep.server.ts new file mode 100644 index 00000000000..1c7ee4d2a51 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatchSweep.server.ts @@ -0,0 +1,417 @@ +/** + * The watch backstop — expiry and lost deliveries, both driven from here. + * + * A watch normally ends on its own last tick: the watcher task runs the final + * check through the private check endpoint and resolves. That depends on the tick + * chain still being alive, and it isn't always — a lost trigger, a run that + * exhausted its retries, a session append that kept failing. Two things are then + * left behind, and both are this sweep's job: + * + * - an `active` row past its deadline, holding one of the chat's three watch + * slots and joining dedup forever, and + * - a resolved row whose wake never reached the chat (`deliveryStatus = pending`), + * i.e. an outcome the user was promised and never got. + * + * The two halves have different dependencies, and that split is load-bearing: + * finalizing a watch needs nothing but the database and the authorization checks, + * while handing a wake over needs a configured agent project to hand it to. So the + * finalization ALWAYS runs — a configuration that disappeared after the watches + * were created (a rotated secret, a rollback) must not freeze every row as `active` + * forever, holding the chat's watch slots. What can't be handed over is simply left + * owed, and the delivery half picks it up when the configuration returns. + * + * It runs in the WEBAPP, not in the agent project, because finalizing a watch is + * an authorization decision: the initiating user is re-authorized against the + * watch's immutable project/environment, a user who lost access gets the watch + * CANCELLED (never woken), and only an authorized watch gets its last check. The + * agent project has none of that — it reads everything through the check endpoint + * with a watch token it cannot mint. So the outcome is decided here and the one + * thing the webapp can't do, appending to a chat's `in` stream, is handed back to + * the watcher task as a delivery-only invocation. + * + * Everything is guarded rather than coordinated: the transition is conditional on + * `active`, the wake is claimed atomically before it is appended, and the action id + * is stable — so the sweep racing a live tick resolves to exactly one winner, and a + * re-run is a no-op. + */ + +import { + cancelWatch, + listExpiredActiveWatches, + listWatchesAwaitingDelivery, + transitionWatchCondition, + type Watch, +} from "@internal/dashboard-agent-db"; +import { + watchResolutionForCheck, + watchResolutionToWireStatus, + type WatchObservedOutcome, + type WatchResolution, +} from "@internal/dashboard-agent-contracts"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { enqueueWatchFiredAlert } from "~/services/dashboardAgentWatchAlerts.server"; +import { + checkWatch, + type WatchCheckDeps, + type WatchCheckOutcome, +} from "~/services/dashboardAgentWatchChecks"; +import { watchCheckDeps } from "~/services/dashboardAgentWatchChecks.server"; +import { isDashboardAgentConfigured } from "~/services/dashboardAgent.server"; +import { + authorizeWatchEnvironment, + scheduleWatchDelivery, + type WatchAuthorization, +} from "~/services/dashboardAgentWatches.server"; +import { logger } from "~/services/logger.server"; + +/** + * How long past `expiresAt` a watch is left to the tick chain before the sweep + * finalizes it. The chain's own final check happens within a cadence of the + * deadline, so this only has to cover a late tick, not a whole check interval. + */ +export const WATCH_EXPIRY_GRACE_MS = 2 * 60 * 1000; + +/** + * How long a resolved watch may owe its wake before the sweep recovers it. The + * normal delivery is seconds; this window keeps the recovery from racing a + * delivery that is still in flight (or being retried by the platform). + */ +export const WATCH_DELIVERY_GRACE_MS = 5 * 60 * 1000; + +/** Per-run cap for each half of the sweep. Oldest first, so the rest land next run. */ +const SWEEP_BATCH_LIMIT = 100; + +/** What one finalization did. */ +export type WatchFinalizeOutcome = + | "fired" + | "expired" + /** The user lost access: cancelled, and deliberately not narrated. */ + | "cancelled" + /** A tick (or another sweep) resolved it first. */ + | "already_resolved"; + +export type WatchSweepResult = { + /** Overdue active rows seen. */ + overdue: number; + fired: number; + expired: number; + cancelled: number; + alreadyResolved: number; + /** Resolved rows whose wake was still owed. */ + undelivered: number; + /** Wakes handed back to the watcher task. */ + redelivered: number; + /** + * Outcomes that were decided but couldn't be handed over, because the agent + * project isn't configured. They stay owed for the next sweep. + */ + deliveryDeferred: number; + failed: number; +}; + +export type WatchSweepDeps = { + now?: () => Date; + limit?: number; + /** Overdue `active` rows. */ + listOverdue?: (params: { now: Date; limit: number }) => Promise; + /** Resolved rows whose wake is still owed. */ + listAwaitingDelivery?: (params: { olderThan: Date; limit: number }) => Promise; + /** Re-authorization of the watch's initiating user. */ + authorize?: (watch: Watch) => Promise; + /** The environment readers the final check runs against. */ + checkDeps?: (environment: AuthenticatedEnvironment, now: Date) => WatchCheckDeps; + /** Hand the wake back to the watcher task. Must throw if it can't be scheduled. */ + deliver?: (watch: Watch) => Promise; + /** + * Whether there is an agent project to hand wakes to. Gates the delivery half + * only — finalization never depends on it. + */ + configured?: () => boolean; +}; + +/** + * Facts for a swept expiry, in the same shape the tick writes (the wake narration + * reads these keys either way). + * + * `verified: false` means the condition itself couldn't be evaluated, so the + * narration must not claim the thing didn't happen — it carries the last + * observation we do have instead. + */ +function expiredFacts( + watch: Watch, + args: { verified: boolean; reason: string; facts?: Record } +): Record { + return { + verified: args.verified, + reason: args.reason, + expiredAt: watch.expiresAt.toISOString(), + checks: watch.tickCount, + ...(args.verified + ? (args.facts ?? {}) + : { + lastObservedAt: watch.lastCheckedAt?.toISOString(), + lastObservation: watch.lastResult, + }), + }; +} + +/** + * How a FINAL check's verdict resolves the row — the window boundary of §7.4. + * + * The last evaluation is a real evaluation: a successful final read may still + * resolve `condition_met` or `condition_impossible`, and only a `pending` or + * `unavailable` result becomes `window_completed`. `watchResolutionForCheck` + * owns that rule; this function only dresses it in the facts the surfaces read. + */ +function resolutionFor( + watch: Watch, + outcome: WatchCheckOutcome +): { + resolution: WatchResolution; + observed: WatchObservedOutcome; + facts: Record; +} { + // Always non-null here: this is the boundary evaluation. + const resolution = watchResolutionForCheck(outcome.result, true)!; + + switch (outcome.result) { + case "satisfied": + return { + resolution, + observed: outcome.observed, + facts: { verified: true, ...outcome.facts }, + }; + case "terminal_unsatisfied": + return { + resolution, + observed: outcome.observed, + facts: expiredFacts(watch, { + verified: true, + reason: "terminal_unsatisfied", + facts: outcome.facts, + }), + }; + case "pending": + return { + resolution, + observed: outcome.observed, + facts: expiredFacts(watch, { + verified: true, + reason: "not_met_by_expiry", + facts: outcome.facts, + }), + }; + default: + // The check couldn't run. The deadline still passed, so the window + // completes — but unverified, never as "it didn't happen". The observation + // carries `verified: false`, which is what makes the presentation say the + // condition couldn't be confirmed. + return { + resolution, + observed: outcome.observed, + facts: expiredFacts(watch, { verified: false, reason: "unverified_at_expiry" }), + }; + } +} + +/** + * Finalize ONE overdue watch: re-authorize, run the last check, resolve, and hand + * the wake to the watcher task. + * + * The order is the point. Re-authorization comes first and a revoked user ends the + * watch as `cancelled` with no wake at all — a watch must never outlive the access + * it was created with, and a cancellation is never narrated. Only then does the + * final check read anything, so the watch gets the same last look a tick past the + * deadline would have given it. + * + * `canDeliver: false` stops at the resolution: the row is terminal with its wake + * owed, which is exactly the state the delivery half recovers. + */ +export async function finalizeOverdueWatch( + watch: Watch, + deps: WatchSweepDeps & { canDeliver?: boolean } = {} +): Promise { + const now = deps.now?.() ?? new Date(); + const authorize = deps.authorize ?? defaultAuthorize; + const buildCheckDeps = deps.checkDeps ?? watchCheckDeps; + const deliver = deps.deliver ?? scheduleWatchDelivery; + const canDeliver = deps.canDeliver ?? true; + + const authorization = await authorize(watch); + if (!authorization.ok) { + await cancelWatch(dashboardAgentDb, { id: watch.id, reason: "access_revoked" }); + logger.info("Dashboard agent watch sweep: cancelled a watch whose access was revoked", { + watchId: watch.id, + }); + return "cancelled"; + } + + const since = watch.spec.since ? new Date(watch.spec.since) : watch.createdAt; + const outcome = await checkWatch( + watch.spec, + buildCheckDeps(authorization.environment, now), + { now, since }, + (error) => + logger.error("Dashboard agent watch sweep: the final check failed", { + watchId: watch.id, + error, + }) + ); + + const resolved = resolutionFor(watch, outcome); + const transitioned = await transitionWatchCondition(dashboardAgentDb, { + id: watch.id, + resolution: resolved.resolution, + observedOutcome: resolved.observed, + lastResult: resolved.facts, + }); + + // Guarded on `active`: a tick resolved it between the list and here, and that + // outcome (with its own delivery) stands. + if (!transitioned) return "already_resolved"; + + if (resolved.resolution === "condition_met") { + // The configured alert channels. Keyed on the watch, so the wake's own + // notification can't double-alert it. + try { + await enqueueWatchFiredAlert(transitioned, "fired"); + } catch (error) { + logger.error("Dashboard agent watch sweep: failed to enqueue the fired alert", { + watchId: watch.id, + error, + }); + } + } + + // The wake itself. Throws if it can't be scheduled, which leaves the row + // terminal with its delivery owed — recovered by the delivery half of the next + // sweep, same as when there is no agent project to hand it to at all. + if (canDeliver) await deliver(transitioned); + return watchResolutionToWireStatus(resolved.resolution); +} + +/** + * Recover ONE owed wake: hand it to the watcher task, whatever left it owed — a + * delivery that failed, a deliverer that died mid-append, or an outcome that was + * resolved inline by the turn that created the watch. + * + * Unconditional on purpose. This sweep cannot tell whether the user was already + * told: every proof available here (the chat's last message time above all) is + * moved by a question, an error turn, or another watch's wake, so acting on it + * loses outcomes. Whether the wake needs prose is decided where the transcript can + * actually be read — the agent's wake narration skips it when the turn that created + * the watch already answered inline, and the delivery is marked either way. + */ +export async function recoverWatchDelivery(watch: Watch, deps: WatchSweepDeps = {}): Promise { + const deliver = deps.deliver ?? scheduleWatchDelivery; + await deliver(watch); +} + +/** + * One sweep: finalize what is overdue, then recover what was never delivered. + * + * Each row is handled on its own — a single failure must not cost the rest of the + * batch — and the run throws at the end if anything failed, so the job is retried + * and the failures are visible. A row left half-done is left in a state the next + * sweep recovers: terminal with the delivery still owed. + */ +export async function sweepDashboardAgentWatches( + deps: WatchSweepDeps = {} +): Promise { + const now = deps.now?.() ?? new Date(); + const limit = deps.limit ?? SWEEP_BATCH_LIMIT; + const configured = deps.configured ?? isDashboardAgentConfigured; + const listOverdue = + deps.listOverdue ?? ((params) => listExpiredActiveWatches(dashboardAgentDb, params)); + const listAwaitingDelivery = + deps.listAwaitingDelivery ?? + ((params) => listWatchesAwaitingDelivery(dashboardAgentDb, params)); + + const result: WatchSweepResult = { + overdue: 0, + fired: 0, + expired: 0, + cancelled: 0, + alreadyResolved: 0, + undelivered: 0, + redelivered: 0, + deliveryDeferred: 0, + failed: 0, + }; + + // Whether there is an agent project to hand a wake to. It gates the hand-off + // only: the rows themselves still have to be finalized, or a configuration that + // vanished after the watches were created would leave every one of them active + // and holding a slot forever. + const canDeliver = configured(); + if (!canDeliver) { + logger.warn( + "Dashboard agent watch sweep: the agent isn't configured, so wakes can't be delivered — finalizing only" + ); + } + + const overdue = await listOverdue({ + now: new Date(now.getTime() - WATCH_EXPIRY_GRACE_MS), + limit, + }); + result.overdue = overdue.length; + + for (const watch of overdue) { + try { + const outcome = await finalizeOverdueWatch(watch, { ...deps, now: () => now, canDeliver }); + if (outcome === "fired") result.fired++; + else if (outcome === "expired") result.expired++; + else if (outcome === "cancelled") result.cancelled++; + else result.alreadyResolved++; + // Resolved, but nothing carried the wake away: it stays owed. + if (!canDeliver && (outcome === "fired" || outcome === "expired")) { + result.deliveryDeferred++; + } + } catch (error) { + result.failed++; + logger.error("Dashboard agent watch sweep: failed to finalize a watch", { + watchId: watch.id, + error, + }); + } + } + + // The delivery half. Skipped wholesale without an agent project — the rows keep + // their owed wake and the next configured sweep recovers them. + if (canDeliver) { + const owed = await listAwaitingDelivery({ + olderThan: new Date(now.getTime() - WATCH_DELIVERY_GRACE_MS), + limit, + }); + result.undelivered = owed.length; + + for (const watch of owed) { + try { + await recoverWatchDelivery(watch, deps); + result.redelivered++; + } catch (error) { + result.failed++; + logger.error("Dashboard agent watch sweep: failed to recover a wake", { + watchId: watch.id, + error, + }); + } + } + } + + if (result.failed > 0) { + throw new Error(`The dashboard agent watch sweep failed on ${result.failed} watches`); + } + + return result; +} + +function defaultAuthorize(watch: Watch): Promise { + return authorizeWatchEnvironment({ + userId: watch.userId, + organizationId: watch.organizationId, + projectId: watch.projectId, + environmentId: watch.environmentId, + }); +} diff --git a/apps/webapp/app/services/dashboardAgentWatchToken.server.ts b/apps/webapp/app/services/dashboardAgentWatchToken.server.ts new file mode 100644 index 00000000000..021840800f4 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatchToken.server.ts @@ -0,0 +1,133 @@ +import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import { env } from "~/env.server"; + +/** + * Watch tokens — the credential the watcher task presents to the private check + * endpoint (`POST /api/v1/dashboard-agent/watches/:watchId/check`). + * + * Deliberately NOT a user-actor token. A UAT authenticates as its user with a + * read cap and is accepted by several `api.v1` routes; a watch token authorizes + * exactly one thing — "ask about this one watch" — and is accepted nowhere else. + * Both are HS256-signed with `SESSION_SECRET`, so the separation has to be + * structural, and it is, twice over: + * + * - a disjoint routing prefix (`tr_daw_` vs `tr_uat_`), so each verifier + * rejects the other's tokens before doing any crypto, and + * - a disjoint `kind` claim inside the JWT (`dashboard_agent_watch` vs + * `user_actor`), so re-prefixing a token by hand doesn't help either. + * + * The token carries NO authority of its own beyond naming the watch: the check + * endpoint re-authorizes the watch's initiating user against the watch's + * immutable project/environment on every call. So a leaked token can't read + * anything the user has since lost access to. + * + * ## Store vs re-mint + * + * The token is NOT persisted. `expirationTime` is absolute (`expiresAt` + grace) + * and `omitIssuedAt` drops the only non-deterministic claim, so signing is a pure + * function of `(SESSION_SECRET, watchId, expiresAt)` — the same inputs mint the + * byte-identical token every time. Anything that needs the token (the creation + * path, or a future sweeper re-scheduling a tick) re-mints it from the watch row + * instead of reading a stored secret, which keeps a long-lived bearer token out + * of the database entirely. + */ + +export const WATCH_TOKEN_PREFIX = "tr_daw_"; + +/** Distinguishes a watch token from every other SESSION_SECRET-signed JWT. */ +const WATCH_TOKEN_KIND = "dashboard_agent_watch"; + +/** + * Mirrors the UAT's `act.client`, with a value no UAT ever uses. Verified, so a + * token minted for some other purpose can't be replayed here. + */ +const WATCH_TOKEN_CLIENT = "dashboard-agent-watch"; + +/** + * How long past `expiresAt` the token stays valid. The expiry evaluation (the + * `final` check) happens after the watch is already past its deadline, so the + * token has to outlive the watch by enough to cover a late tick. + */ +export const WATCH_TOKEN_GRACE_MS = 60 * 60 * 1000; + +export type WatchTokenClaims = { + watchId: string; + /** Token expiry (seconds since epoch), i.e. `expiresAt` + grace. */ + expiresAtSeconds: number; +}; + +export function isDashboardAgentWatchToken(token: string): boolean { + return token.startsWith(WATCH_TOKEN_PREFIX); +} + +/** + * Sign a watch token. Deterministic: same secret + watchId + expiresAt produce + * the same string (see the store-vs-re-mint note above). + */ +export async function signDashboardAgentWatchToken( + secret: string, + opts: { watchId: string; expiresAt: Date; graceMs?: number } +): Promise { + const expirationTime = Math.floor( + (opts.expiresAt.getTime() + (opts.graceMs ?? WATCH_TOKEN_GRACE_MS)) / 1000 + ); + + const jwt = await generateJWT({ + secretKey: secret, + payload: { + kind: WATCH_TOKEN_KIND, + // `sub` is the watch, not a user — a watch token never authenticates a user. + sub: opts.watchId, + act: { client: WATCH_TOKEN_CLIENT }, + }, + expirationTime, + omitIssuedAt: true, + }); + + return `${WATCH_TOKEN_PREFIX}${jwt}`; +} + +/** + * `undefined` for anything that isn't a valid, unexpired, correctly-signed watch + * token — including a perfectly valid user-actor token. + */ +export async function verifyDashboardAgentWatchToken( + secret: string, + token: string +): Promise { + if (!isDashboardAgentWatchToken(token)) return; + + const result = await validateJWT(token.slice(WATCH_TOKEN_PREFIX.length), secret); + if (!result.ok) return; + + const payload = result.payload; + if (payload.kind !== WATCH_TOKEN_KIND) return; + if (typeof payload.sub !== "string" || payload.sub.length === 0) return; + + const act = payload.act as { client?: string } | undefined; + if (act?.client !== WATCH_TOKEN_CLIENT) return; + if (typeof payload.exp !== "number") return; + + return { watchId: payload.sub, expiresAtSeconds: payload.exp }; +} + +/** Mint the token for a watch row, using the platform secret. */ +export function mintDashboardAgentWatchToken(opts: { + watchId: string; + expiresAt: Date; +}): Promise { + return signDashboardAgentWatchToken(env.SESSION_SECRET, opts); +} + +/** Verify a bearer token presented to the check endpoint. */ +export function verifyWatchTokenFromRequest(token: string): Promise { + return verifyDashboardAgentWatchToken(env.SESSION_SECRET, token); +} + +/** The bearer value from an `Authorization: Bearer …` header, if present. */ +export function bearerToken(request: Request): string | undefined { + const raw = request.headers.get("Authorization"); + if (!raw) return undefined; + const value = raw.replace(/^Bearer /, "").trim(); + return value.length > 0 ? value : undefined; +} diff --git a/apps/webapp/app/services/dashboardAgentWatches.server.ts b/apps/webapp/app/services/dashboardAgentWatches.server.ts new file mode 100644 index 00000000000..ac1a2784741 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatches.server.ts @@ -0,0 +1,589 @@ +/** + * Watches — the webapp half. Creation (with its guardrails), the re-authorization + * a background check has to pass, and the chat-delete cascade. + * + * The one invariant everything here serves: **a watch fires with exactly the + * access its creator had, and no more.** The org/project/environment/user snapshot + * on the row is immutable, every check re-authorizes that snapshot against the + * user's CURRENT access, and losing access cancels the watch rather than + * degrading it. Nothing in this file takes a project/environment from client + * input — callers hand in an already-authorized `AuthenticatedEnvironment`. + */ + +import { + MAX_ACTIVE_WATCHES_PER_CHAT, + cancelWatch, + chatExists, + createWatch, + getChatWatchContext, + listActiveWatchesForChats as listActiveWatchesForChatsQuery, + precheckWatchCreation, + softDeleteChat, + type ChatWatchContext, + type PersistedWatchSpec, + type WatchStatus, +} from "@internal/dashboard-agent-db"; +import { + watchIdentity, + type WatchObservedOutcome, + type WatchResolution, + type WatchSpec, +} from "@internal/dashboard-agent-contracts"; +import { TriggerClient } from "@trigger.dev/sdk"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { $replica, prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { authIncludeWithParent, toAuthenticated } from "~/models/runtimeEnvironment.server"; +import { isReportKey } from "~/presenters/v3/reports/report-registry"; +import { + dashboardAgentApiOrigin, + isDashboardAgentConfigured as isDashboardAgentConfiguredDefault, +} from "~/services/dashboardAgent.server"; +import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { logger } from "~/services/logger.server"; +import { + checkWatch, + type WatchCheckDeps, + type WatchCheckOutcome, +} from "~/services/dashboardAgentWatchChecks"; +import { watchCheckDeps } from "~/services/dashboardAgentWatchChecks.server"; +import { mintDashboardAgentWatchToken } from "~/services/dashboardAgentWatchToken.server"; +import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server"; + +/** The task that polls a watch. Lives in the agent project, triggered by us. */ +export const WATCH_TASK_ID = "dashboard-agent-watch"; + +export { MAX_ACTIVE_WATCHES_PER_CHAT }; + +// --------------------------------------------------------------------------- +// Re-authorization — the gate every background check has to pass. +// --------------------------------------------------------------------------- + +export type WatchAuthorization = + | { ok: true; environment: AuthenticatedEnvironment } + | { ok: false; reason: "access_revoked" }; + +/** + * Re-authorize a watch's initiating user against the watch's IMMUTABLE + * project/environment, through the same checks an interactive dashboard request + * makes: org membership, a live (non-archived) environment in a live project, the + * per-member rule for dev environments, and the dashboard-agent feature gate. + * + * Deliberately one query plus the feature flag — it runs on every tick. + * + * Anything short of a full pass is `access_revoked`: lost membership, a deleted + * project, an archived environment, a revoked feature flag. The caller cancels the + * watch on that answer, so a watch can only ever narrow, never widen. + * + * OSS caveat (VERDICTS): the self-hosted RBAC fallback ability is permissive, so + * the membership-scoped query — not `ability.can(...)` — is the tenant floor here, + * exactly as it is on the PAT-authenticated API routes. + */ +export async function authorizeWatchEnvironment(params: { + userId: string; + organizationId: string; + projectId: string; + environmentId: string; +}): Promise { + // The PRIMARY, not the replica: this is the authorization boundary every + // background tick passes through, and replica lag would extend access the user + // has already lost. + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + id: params.environmentId, + // The watch's snapshot has to still describe this environment — a mismatch + // means the row is being used to reach somewhere it was never created for. + projectId: params.projectId, + organizationId: params.organizationId, + archivedAt: null, + project: { deletedAt: null }, + organization: { deletedAt: null, members: { some: { userId: params.userId } } }, + OR: [ + { type: { in: ["PREVIEW", "STAGING", "PRODUCTION"] } }, + // Dev environments are per-member: only their owner may read them. + { type: "DEVELOPMENT", orgMember: { userId: params.userId } }, + ], + }, + include: authIncludeWithParent, + }); + + if (!environment) return { ok: false, reason: "access_revoked" }; + + const user = await $replica.user.findFirst({ + where: { id: params.userId }, + select: { admin: true }, + }); + if (!user) return { ok: false, reason: "access_revoked" }; + + const allowed = await canAccessDashboardAgent({ + userId: params.userId, + isAdmin: user.admin, + // A background check is never an impersonated session. + isImpersonating: false, + organizationSlug: environment.organization.slug, + orgFeatureFlags: environment.organization.featureFlags as Record | null, + }); + if (!allowed) return { ok: false, reason: "access_revoked" }; + + return { ok: true, environment: toAuthenticated(environment) }; +} + +/** + * The same authorization, addressed by environment id alone — for the creation + * path, where no watch row (and so no org/project snapshot to cross-check) exists + * yet. The id lookup is unscoped on purpose and proves nothing; every membership, + * dev-owner and feature-gate rule is applied by `authorizeWatchEnvironment` below + * it, so an id the user can't reach still resolves to `null`. + */ +export async function authorizeWatchEnvironmentById(params: { + userId: string; + environmentId: string; +}): Promise { + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { id: params.environmentId }, + select: { organizationId: true, projectId: true }, + }); + if (!environment) return null; + + const authorization = await authorizeWatchEnvironment({ + userId: params.userId, + organizationId: environment.organizationId, + projectId: environment.projectId, + environmentId: params.environmentId, + }); + return authorization.ok ? authorization.environment : null; +} + +// --------------------------------------------------------------------------- +// Creation. +// --------------------------------------------------------------------------- + +export type CreateWatchErrorCode = + | "limit_reached" + | "duplicate" + | "invalid_target" + | "chat_not_found" + | "not_configured" + | "internal"; + +/** + * What creation answered with. + * + * Two shapes, because the resolution model gives the immediate check its own + * ending: either a watch is now running (`watching: true`), or the check already + * answered the request and **no watch row exists at all** (`watching: false`). + * The second one is the ONE-SHOT RESULT BLOCK of §2.2/§4.1 — it never enters the + * watch delivery state machine: no row, no claim, no chip, no wake. + */ +export type CreateDashboardAgentWatchResult = + | { + ok: true; + watching: true; + watchId: string; + identity: string; + status: WatchStatus; + expiresAt: Date; + /** + * Set when the creation-time check couldn't run. The watch is active + * anyway; the confirmation says "We couldn't check that just now." + */ + unavailable?: boolean; + } + | { + ok: true; + watching: false; + identity: string; + /** `satisfied` (already true) or `terminal_unsatisfied` (can't happen now). */ + immediate: WatchCheckOutcome; + } + | { + ok: false; + error: string; + code: CreateWatchErrorCode; + /** The watch already covering this condition, on `duplicate`. */ + existingId?: string | null; + }; + +/** + * Cheap existence check for the thing a spec points at, in THIS environment. It + * exists so a watch can't be created against a run or queue in someone else's + * environment (or a typo), which would then poll for its whole lifetime and + * expire with nothing to say. + * + * `error_recurrence` has nothing to validate on purpose: a fingerprint with zero + * occurrences so far is the normal case for "tell me if this comes back". + */ +async function validateWatchTarget(spec: WatchSpec, deps: WatchCheckDeps): Promise { + switch (spec.kind) { + case "run_start": + case "run_finished": + case "run_failed": + return (await deps.readRun(spec.runId)) !== null; + case "backlog_drain": + case "queue_depth_above": + return await deps.queueExists(spec.queue); + case "error_recurrence": + return spec.fingerprint.length > 0; + case "health_recovery": + return isReportKey(spec.report); + } +} + +/** + * Create a watch for an ALREADY-AUTHORIZED context. + * + * The caller (a UAT endpoint or a dashboard session action) has resolved the + * environment and proven the chat belongs to this user; this function owns + * everything after that. + * + * The order is §4.4's, and it is load-bearing: **cap → dedup → immediate check → + * create**. The guardrails are consulted first, so "you already have three" and + * "you're already watching this" are answered the same way whether or not the + * condition happens to be true right now. Then the immediate check runs, and: + * + * - `satisfied` / `terminal_unsatisfied` → **no row is written**. The check + * answered the request; the caller renders a one-shot result block and the + * agent answers from it. There is no chip, no wake, and nothing to cancel. + * - `pending` / `unavailable` → the watch is created and the first tick + * scheduled. `unavailable` is not a verdict, so it never resolves anything — + * the confirmation just says we couldn't check yet. + * + * A watch is never left active-but-unwatched: if the first tick can't be + * scheduled, the row is CANCELLED (silent, no resolution, no wake — a scheduling + * failure is not an answer about the user's condition) and a plain failure is + * returned. + */ +export async function createDashboardAgentWatch(params: { + environment: AuthenticatedEnvironment; + userId: string; + chatId: string; + spec: WatchSpec; + /** + * Consent to investigate after an attention outcome (§6). Only ever true when + * the user asked for it at creation — it is never inferred here, and it is not + * part of the spec or the identity. + */ + investigateOnAttention?: boolean; + now?: Date; + /** IO seams — tests inject fakes here instead of mocking the readers. */ + deps?: { + checkDeps?: (environment: AuthenticatedEnvironment, now: Date) => WatchCheckDeps; + scheduleTick?: typeof scheduleWatchTick; + /** Skip the real trigger-config gate when a tick scheduler is injected. */ + configured?: () => boolean; + }; +}): Promise { + const { environment, userId, chatId, spec } = params; + const now = params.now ?? new Date(); + const buildCheckDeps = params.deps?.checkDeps ?? watchCheckDeps; + const scheduleTick = params.deps?.scheduleTick ?? scheduleWatchTick; + const isDashboardAgentConfigured = params.deps?.configured ?? isDashboardAgentConfiguredDefault; + const checkDeps = buildCheckDeps(environment, now); + + if (!isDashboardAgentConfigured()) { + return { + ok: false, + code: "not_configured", + error: "The dashboard agent is not configured, so watches can't be scheduled.", + }; + } + + if (!(await validateWatchTarget(spec, checkDeps))) { + return { + ok: false, + code: "invalid_target", + error: "That target doesn't exist in this environment.", + }; + } + + const identity = watchIdentity(spec); + + // The guardrails, before the check and before any write. Advisory (a plain + // read); `createWatch` below re-applies both atomically and stays the authority. + const precheck = await precheckWatchCreation(dashboardAgentDb, { + chatId, + projectId: environment.projectId, + environmentId: environment.id, + identity, + }); + if (!precheck.ok) return creationGuardrailError(precheck); + + // `since` is SERVER-SET so the model can't backdate a recurrence window and + // make a pre-existing error look like a recurrence. + const persistedSpec: PersistedWatchSpec = + spec.kind === "error_recurrence" ? { ...spec, since: now.toISOString() } : spec; + + // The creation-time check. Many watches are asked for after the condition has + // already happened, and answering in the same turn beats waiting a cadence — + // and under the resolution model that answer needs no watch at all. + const immediate = await checkWatch(persistedSpec, checkDeps, { now, since: now }, (error) => + logger.error("Dashboard agent watch: immediate check failed", { chatId, identity, error }) + ); + + if (immediate.result === "satisfied" || immediate.result === "terminal_unsatisfied") { + // The one-shot result block. Nothing is persisted here on purpose: no row + // means no chip, no delivery claim, and no wake that could tell the user a + // second time (§7.5). + return { ok: true, watching: false, identity, immediate }; + } + + const expiresAt = new Date(now.getTime() + spec.maxHours * 60 * 60 * 1000); + + const created = await createWatch(dashboardAgentDb, { + chatId, + identity, + spec: persistedSpec, + organizationId: environment.organizationId, + projectId: environment.projectId, + // The external ref travels with the row so a wake can scope an investigation + // the same way a turn does — the agent can't translate the internal id. + projectRef: environment.project.externalRef, + environmentId: environment.id, + userId, + expiresAt, + investigateOnAttention: params.investigateOnAttention === true, + }); + + if (!created.ok) { + if (created.error === "chat_not_found") { + // The chat was deleted while this create was in flight — the query layer + // re-reads it under the per-chat lock, so nothing was written. + return { + ok: false, + code: "chat_not_found", + error: "That chat no longer exists, so nothing is being watched.", + }; + } + return creationGuardrailError(created); + } + + const watch = created.watch; + const token = await mintDashboardAgentWatchToken({ watchId: watch.id, expiresAt }); + + try { + await scheduleTick({ + watchId: watch.id, + token, + delayMinutes: spec.checkEveryMinutes, + // The GENERATION the first tick will claim: the row is on `tickCount` + // (0 here) and each invocation claims its own generation atomically, so the + // first one is `tickCount + 1`. + tick: watch.tickCount + 1, + }); + } catch (error) { + logger.error("Dashboard agent watch: failed to schedule the first tick", { + id: watch.id, + error, + }); + // Nothing will ever check this watch, so don't leave it sitting active and + // silently blocking a re-ask. CANCELLED, not resolved: the user's condition + // was never evaluated, and a resolution would have the agent narrate a verdict + // nobody measured. Cancellation is silent, so no wake is ever sent. + await cancelWatch(dashboardAgentDb, { id: watch.id, reason: "scheduling_failed" }); + return { + ok: false, + code: "internal", + error: "The watch couldn't be scheduled. Nothing is being watched.", + }; + } + + return { + ok: true, + watching: true, + watchId: watch.id, + identity, + status: "active", + expiresAt, + ...(immediate.result === "unavailable" ? { unavailable: true } : {}), + }; +} + +/** The two guardrail refusals, worded once for both the pre-check and the insert. */ +function creationGuardrailError( + refusal: + | { error: "limit_reached"; activeCount: number } + | { error: "duplicate"; existingId: string | null } +): CreateDashboardAgentWatchResult { + if (refusal.error === "limit_reached") { + return { + ok: false, + code: "limit_reached", + error: `This chat already has ${MAX_ACTIVE_WATCHES_PER_CHAT} active watches. Cancel one first.`, + }; + } + return { + ok: false, + code: "duplicate", + error: "This chat is already watching that.", + existingId: refusal.existingId, + }; +} + +/** + * Trigger one tick of the watcher task in the agent's project, as the agent's own + * environment (`DASHBOARD_AGENT_SECRET_KEY`) — the same credential and version + * pinning `dashboardAgent.server.ts` uses for the agent itself. + * + * The token travels in the payload rather than the database: it's a pure function + * of `(SESSION_SECRET, watchId, expiresAt)`, so a re-schedule can always re-mint + * an identical one (see `dashboardAgentWatchToken.server.ts`). + */ +export async function scheduleWatchTick(params: { + watchId: string; + token: string; + delayMinutes: number; + /** The tick generation the scheduled invocation claims. */ + tick: number; +}): Promise { + const accessToken = env.DASHBOARD_AGENT_SECRET_KEY; + if (!accessToken) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set"); + + const apiOrigin = dashboardAgentApiOrigin(); + const client = new TriggerClient({ baseURL: apiOrigin, accessToken }); + + await client.tasks.trigger( + WATCH_TASK_ID, + // The watcher task's payload contract: the watch, its token, the origin to + // call the check endpoint on, and the tick generation this invocation owns. + { watchId: params.watchId, token: params.token, apiOrigin, tick: params.tick }, + { + delay: `${params.delayMinutes}m`, + // Keyed on the same generation the payload carries, so a retried schedule + // can't double-tick. + idempotencyKey: `watch:${params.watchId}:tick:${params.tick}`, + // Pin to the same deployed agent version the chat runs on, when set. + ...(env.DASHBOARD_AGENT_VERSION ? { version: env.DASHBOARD_AGENT_VERSION } : {}), + } + ); +} + +/** + * Hand a RESOLVED watch's wake to the watcher task. + * + * The webapp decides outcomes (it owns the authorization and the check), the agent + * project owns appending to a chat's `in` stream — so a wake the webapp resolved is + * delivered by a delivery-only invocation of the same task a tick uses. It takes + * the same durable path: append, then mark the delivery, with the stable action id + * doing the dedup if it runs twice. + * + * Keyed per watch (`watch:{id}:deliver`) with a short TTL, so repeated sweeps + * inside one window collapse into one invocation while a later sweep can still try + * again after a run failed for good. + * + * The token is the row's own deterministic one. Past the watch's grace window it is + * expired, which costs nothing here: a delivery-only invocation never calls the + * check endpoint, and the alert fan-out is enqueued by the caller. + */ +export async function scheduleWatchDelivery(watch: { id: string; expiresAt: Date }): Promise { + const accessToken = env.DASHBOARD_AGENT_SECRET_KEY; + if (!accessToken) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set"); + + const apiOrigin = dashboardAgentApiOrigin(); + const client = new TriggerClient({ baseURL: apiOrigin, accessToken }); + const token = await mintDashboardAgentWatchToken({ + watchId: watch.id, + expiresAt: watch.expiresAt, + }); + + await client.tasks.trigger( + WATCH_TASK_ID, + { watchId: watch.id, token, apiOrigin, tick: 0, deliverOnly: true }, + { + idempotencyKey: `watch:${watch.id}:deliver`, + idempotencyKeyTTL: "10m", + ...(env.DASHBOARD_AGENT_VERSION ? { version: env.DASHBOARD_AGENT_VERSION } : {}), + } + ); +} + +// --------------------------------------------------------------------------- +// Chat lifecycle + the list view. +// --------------------------------------------------------------------------- + +/** + * Delete a chat and end its watches in ONE transaction: the conversation they'd + * wake is gone, so there's nowhere to deliver an outcome, and a half-applied + * delete would leave live watches on a chat the user can't see. Owner-scoped, so + * a chatId the caller doesn't own deletes nothing. + */ +export async function deleteChatWithWatches(params: { + chatId: string; + userId: string; +}): Promise<{ deleted: boolean; cancelledWatches: number }> { + const result = await softDeleteChat(dashboardAgentDb, params); + return { deleted: result.deleted, cancelledWatches: result.cancelledWatches.length }; +} + +/** + * What the panel needs to show a chat's watch chips. The dates are strings + * because this crosses a loader's JSON boundary. + */ +export type ChatWatchChip = { + id: string; + identity: string; + status: WatchStatus; + kind: string; + note: string; + checkEveryMinutes: number; + expiresAt: string; + endedReason: string | null; + /** How the watch ended — what the wake banner presents. Null while active. */ + resolution: WatchResolution | null; + /** What the resolving check observed — the other half of the headline. */ + observedOutcome: WatchObservedOutcome | null; +}; + +/** + * Active watches for many chats in ONE query, keyed by chatId — the panel and + * history list must not fan out a query per chat. The query layer re-scopes the + * chat ids by org + user, so this is safe with ids from any source. + */ +export async function listActiveWatchesForChats(params: { + chatIds: string[]; + organizationId: string; + userId: string; +}): Promise> { + const byChat = await listActiveWatchesForChatsQuery(dashboardAgentDb, params); + + return Object.fromEntries( + Object.entries(byChat).map(([chatId, watches]) => [ + chatId, + watches.map((watch) => ({ + id: watch.id, + identity: watch.identity, + status: watch.status, + kind: watch.kind, + note: watch.note, + checkEveryMinutes: watch.checkEveryMinutes, + expiresAt: watch.expiresAt.toISOString(), + endedReason: watch.endedReason, + resolution: watch.resolution, + observedOutcome: watch.observedOutcome, + })), + ]) + ); +} + +/** Owner check for a chat, for the adapters. */ +export function chatBelongsToUser(params: { + chatId: string; + userId: string; + organizationId: string; +}): Promise { + return chatExists(dashboardAgentDb, params); +} + +export type { ChatWatchContext }; + +/** + * Ownership check for a chat — a live chat owned by this user — plus the org it + * belongs to, which is the tenancy floor its watches can't leave. Deliberately no + * project/environment: those come from the authorized request context, never from + * the chat row (see `getChatWatchContext`). + */ +export function resolveChatWatchContext(params: { + chatId: string; + userId: string; +}): Promise { + return getChatWatchContext(dashboardAgentDb, params); +} diff --git a/apps/webapp/app/v3/alertsWorker.server.ts b/apps/webapp/app/v3/alertsWorker.server.ts index 88637d1c361..e54ffe86f81 100644 --- a/apps/webapp/app/v3/alertsWorker.server.ts +++ b/apps/webapp/app/v3/alertsWorker.server.ts @@ -5,11 +5,40 @@ import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { singleton } from "~/utils/singleton"; import { DeliverAlertService } from "./services/alerts/deliverAlert.server"; +import { + DeliverDashboardAgentWatchAlertService, + DeliverDashboardAgentWatchChannelAlertService, +} from "./services/alerts/deliverDashboardAgentWatchAlert.server"; import { DeliverErrorGroupAlertService } from "./services/alerts/deliverErrorGroupAlert.server"; import { ErrorAlertEvaluator } from "./services/alerts/errorAlertEvaluator.server"; +import { + watchObservedOutcomeSchema, + watchResolutionSchema, +} from "@internal/dashboard-agent-contracts"; import { PerformDeploymentAlertsService } from "./services/alerts/performDeploymentAlerts.server"; import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server"; +/** The fired watch, as the fan-out and each per-channel delivery carry it. */ +const DashboardAgentWatchAlertPayload = z.object({ + watchId: z.string(), + organizationId: z.string(), + projectId: z.string(), + environmentId: z.string(), + userId: z.string(), + identity: z.string(), + kind: z.string(), + note: z.string(), + firedAt: z.string(), + facts: z.record(z.unknown()), + // The resolution model, carried alongside the as-built fields (§7.5). Optional + // so a job enqueued before this deploy still validates and delivers. + resolution: watchResolutionSchema.optional().catch(undefined), + // `.catch` rather than a hard parse: an observation shape this build doesn't + // recognize must degrade to "no observation" (the headline then uses the + // kind's default cell), never drop the whole alert. + observed: watchObservedOutcomeSchema.optional().catch(undefined), +}); + function initializeWorker() { const redisOptions = { keyPrefix: "alerts:worker:", @@ -93,6 +122,24 @@ function initializeWorker() { }, logErrors: true, }, + // The fan-out: resolves the channels and enqueues one delivery job each. + "v3.deliverDashboardAgentWatchAlert": { + schema: DashboardAgentWatchAlertPayload, + visibilityTimeoutMs: 60_000, + retry: { + maxAttempts: 3, + }, + logErrors: true, + }, + // One channel's delivery, so a retry only re-sends the channel that failed. + "v3.deliverDashboardAgentWatchAlertChannel": { + schema: DashboardAgentWatchAlertPayload.extend({ channelId: z.string() }), + visibilityTimeoutMs: 60_000, + retry: { + maxAttempts: 3, + }, + logErrors: true, + }, }, concurrency: { workers: env.ALERTS_WORKER_CONCURRENCY_WORKERS, @@ -126,6 +173,14 @@ function initializeWorker() { const service = new DeliverErrorGroupAlertService(); await service.call(payload); }, + "v3.deliverDashboardAgentWatchAlert": async ({ payload }) => { + const service = new DeliverDashboardAgentWatchAlertService(); + await service.call(payload); + }, + "v3.deliverDashboardAgentWatchAlertChannel": async ({ payload }) => { + const service = new DeliverDashboardAgentWatchChannelAlertService(); + await service.call(payload); + }, }, }); diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts index 87093c36aae..f9abe6c9cca 100644 --- a/apps/webapp/app/v3/commonWorker.server.ts +++ b/apps/webapp/app/v3/commonWorker.server.ts @@ -1,5 +1,5 @@ import { Logger } from "@trigger.dev/core/logger"; -import { Worker as RedisWorker } from "@trigger.dev/redis-worker"; +import { CronSchema, Worker as RedisWorker } from "@trigger.dev/redis-worker"; import { DeliverEmailSchema } from "emails"; import { z } from "zod"; import { env } from "~/env.server"; @@ -11,6 +11,7 @@ import { runAttioUserSync, runAttioWorkspaceSync, } from "~/services/attio.server"; +import { sweepDashboardAgentWatches } from "~/services/dashboardAgentWatchSweep.server"; import { logger } from "~/services/logger.server"; import { MembershipDevEnvironmentsSchema, @@ -146,6 +147,18 @@ function initializeWorker() { maxAttempts: 5, }, }, + // The dashboard agent's watch backstop: expire what is overdue (through the + // same authorization a check goes through) and re-deliver wakes that never + // reached their chat. See dashboardAgentWatchSweep.server.ts. + "dashboardAgent.sweepWatches": { + schema: CronSchema, + visibilityTimeoutMs: 60_000 * 5, + cron: "*/5 * * * *", + jitterInMs: 30_000, + retry: { + maxAttempts: 1, + }, + }, }, concurrency: { workers: env.COMMON_WORKER_CONCURRENCY_WORKERS, @@ -204,6 +217,12 @@ function initializeWorker() { const service = new BulkActionService(); await service.process(payload.bulkActionId); }, + "dashboardAgent.sweepWatches": async () => { + const result = await sweepDashboardAgentWatches(); + if (result.overdue > 0 || result.undelivered > 0) { + logger.debug("Dashboard agent watch sweep", result); + } + }, }, }); diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index 1a54581b71a..89521d483b4 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -391,9 +391,10 @@ export class DeliverAlertService extends BaseService { break; } - case "ERROR_GROUP": { - // A payload-carried alert type: it never creates a ProjectAlert row, so - // this service never sees it. + case "ERROR_GROUP": + case "DASHBOARD_AGENT_WATCH": { + // Payload-carried alert types: they never create a ProjectAlert row, so + // this service never sees them. break; } default: { @@ -748,9 +749,10 @@ export class DeliverAlertService extends BaseService { break; } - case "ERROR_GROUP": { - // A payload-carried alert type: it never creates a ProjectAlert row, so - // this service never sees it. + case "ERROR_GROUP": + case "DASHBOARD_AGENT_WATCH": { + // Payload-carried alert types: they never create a ProjectAlert row, so + // this service never sees them. break; } default: { @@ -1026,9 +1028,10 @@ export class DeliverAlertService extends BaseService { return; } } - case "ERROR_GROUP": { - // A payload-carried alert type: it never creates a ProjectAlert row, so - // this service never sees it. + case "ERROR_GROUP": + case "DASHBOARD_AGENT_WATCH": { + // Payload-carried alert types: they never create a ProjectAlert row, so + // this service never sees them. break; } default: { diff --git a/apps/webapp/app/v3/services/alerts/deliverDashboardAgentWatchAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverDashboardAgentWatchAlert.server.ts new file mode 100644 index 00000000000..0059d09395e --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/deliverDashboardAgentWatchAlert.server.ts @@ -0,0 +1,511 @@ +import { + type ChatPostMessageArguments, + ErrorCode, + type WebAPIPlatformError, + type WebAPIRateLimitedError, +} from "@slack/web-api"; +import type { WatchObservedOutcome, WatchResolution } from "@internal/dashboard-agent-contracts"; +import { type ProjectAlertChannel } from "@trigger.dev/database"; +import assertNever from "assert-never"; +import { subtle } from "crypto"; +import { $replica, prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { + isIntegrationForService, + type OrganizationIntegrationForService, + OrgIntegrationRepository, +} from "~/models/orgIntegration.server"; +import { + ProjectAlertEmailProperties, + ProjectAlertSlackProperties, + ProjectAlertWebhookProperties, +} from "~/models/projectAlert.server"; +import { mintDashboardAgentAlertUnsubscribeToken } from "~/services/dashboardAgentAlertUnsubscribeToken.server"; +import { + canUseDashboardAgentAlerts, + DASHBOARD_AGENT_WATCH_ALERT_TYPE, +} from "~/services/dashboardAgentWatchAlerts.server"; +import { presentResolvedWatch } from "~/components/dashboard-agent/watch-presentation"; +import { sendAlertEmail } from "~/services/email.server"; +import { logger } from "~/services/logger.server"; +import { decryptSecret } from "~/services/secrets/secretStore.server"; +import { v3RunsPath } from "~/utils/pathBuilder"; +import { alertsWorker } from "~/v3/alertsWorker.server"; +import { safeWebhookFetch } from "./safeWebhookFetch.server"; + +/** + * Deliver a fired dashboard-agent watch to the project's alert channels. + * + * Payload-carried, like the error-group alert: no `ProjectAlert` row. A watch is + * already a durable row in the dashboard-agent database with its own delivery + * state, so a second row tracking the same event would only be a thing to keep in + * sync. The job ids are the dedupe. + * + * Two steps, as with the error-group path: a fan-out (`watch-alert:{watchId}`, + * this payload) resolves the environment, the gate and the matching channels, then + * enqueues one delivery job per channel (`watch-alert:{watchId}:channel:{channelId}`). + * A delivery job sends exactly one channel, so a webhook failing can only ever + * re-send that webhook — never the email and Slack that already went out. + */ +export type DashboardAgentWatchAlertPayload = { + watchId: string; + organizationId: string; + projectId: string; + environmentId: string; + userId: string; + identity: string; + kind: string; + note: string; + firedAt: string; + facts: Record; + /** + * How the watch ended and what the resolving check observed — the two halves + * the headline is built from (§4.2). Optional so a job enqueued by an older + * build still delivers; absent, the headline falls back to `condition_met` + * with no observation, which is the only resolution that alerts today. + */ + resolution?: WatchResolution; + observed?: WatchObservedOutcome; +}; + +/** One channel's delivery: the fan-out payload plus the channel it targets. */ +export type DashboardAgentWatchChannelAlertPayload = DashboardAgentWatchAlertPayload & { + channelId: string; +}; + +/** Bumped when the webhook body's shape changes. */ +const WEBHOOK_VERSION = "2026-08-02"; + +/** + * The one place this service words a watch result — and it doesn't word it + * itself: it asks the shared presenter, so the email subject, the Slack line and + * the chat's wake banner are the same sentence (§6, visual continuity). + */ +function presentAlert(payload: DashboardAgentWatchAlertPayload) { + return presentResolvedWatch({ + kind: payload.kind, + identity: payload.identity, + // Only a met condition fans out today; the fallback keeps an older payload + // from silently presenting as something else. + resolution: payload.resolution ?? "condition_met", + observed: payload.observed ?? null, + }); +} + +class SkipRetryError extends Error {} + +type ResolvedContext = { + environmentName: string; + environmentSlug: string; + organizationSlug: string; + organizationTitle: string; + projectName: string; + projectSlug: string; + projectRef: string; + dashboardLink: string; +}; + +type ResolvedEnvironment = NonNullable>>; + +function findEnvironment(payload: DashboardAgentWatchAlertPayload) { + return $replica.runtimeEnvironment.findFirst({ + where: { id: payload.environmentId, projectId: payload.projectId }, + select: { + type: true, + slug: true, + branchName: true, + project: { + select: { + name: true, + slug: true, + externalRef: true, + organization: { select: { slug: true, title: true } }, + }, + }, + }, + }); +} + +function buildContext(environment: ResolvedEnvironment): ResolvedContext { + return { + environmentName: environment.branchName ?? environment.slug, + environmentSlug: environment.slug, + organizationSlug: environment.project.organization.slug, + organizationTitle: environment.project.organization.title, + projectName: environment.project.name, + projectSlug: environment.project.slug, + projectRef: environment.project.externalRef, + dashboardLink: `${env.APP_ORIGIN}${v3RunsPath( + { slug: environment.project.organization.slug }, + { slug: environment.project.slug }, + { slug: environment.slug } + )}`, + }; +} + +/** The fan-out: gate the watch, then enqueue one delivery job per channel. */ +export class DeliverDashboardAgentWatchAlertService { + async call(payload: DashboardAgentWatchAlertPayload): Promise { + const environment = await findEnvironment(payload); + + if (!environment) { + logger.warn("[DeliverDashboardAgentWatchAlert] Environment not found", { + watchId: payload.watchId, + }); + return; + } + + // The gate, checked at DELIVERY and not only at subscribe time, so a plan + // change or a revoked feature flag stops the alerts without anyone having to + // clean up channels. + const gate = await canUseDashboardAgentAlerts({ + userId: payload.userId, + organizationId: payload.organizationId, + organizationSlug: environment.project.organization.slug, + }); + if (!gate.allowed) { + logger.info("[DeliverDashboardAgentWatchAlert] Not allowed for this organization", { + watchId: payload.watchId, + reason: gate.reason, + }); + return; + } + + const channels = await $replica.projectAlertChannel.findMany({ + where: { + projectId: payload.projectId, + enabled: true, + alertTypes: { has: DASHBOARD_AGENT_WATCH_ALERT_TYPE }, + environmentTypes: { has: environment.type }, + }, + select: { id: true }, + }); + + for (const channel of channels) { + await alertsWorker.enqueue({ + // Stable per channel, so a fan-out retry re-enqueues the same job ids + // rather than a second alert per channel. + id: `watch-alert:${payload.watchId}:channel:${channel.id}`, + job: "v3.deliverDashboardAgentWatchAlertChannel", + payload: { ...payload, channelId: channel.id }, + }); + } + } +} + +/** One channel's delivery. A retry here can only re-send this channel. */ +export class DeliverDashboardAgentWatchChannelAlertService { + async call(payload: DashboardAgentWatchChannelAlertPayload): Promise { + // Re-read the channel rather than trusting the fan-out's snapshot: an + // unsubscribe between fan-out and delivery should stop the alert. + const channel = await $replica.projectAlertChannel.findFirst({ + where: { + id: payload.channelId, + projectId: payload.projectId, + enabled: true, + alertTypes: { has: DASHBOARD_AGENT_WATCH_ALERT_TYPE }, + }, + }); + + if (!channel) { + logger.info("[DeliverDashboardAgentWatchAlert] Channel gone or unsubscribed", { + watchId: payload.watchId, + channelId: payload.channelId, + }); + return; + } + + const environment = await findEnvironment(payload); + + if (!environment) { + logger.warn("[DeliverDashboardAgentWatchAlert] Environment not found", { + watchId: payload.watchId, + }); + return; + } + + const context = buildContext(environment); + + try { + switch (channel.type) { + case "EMAIL": + await this.#sendEmail(channel, payload, context); + break; + case "SLACK": + await this.#sendSlack(channel, payload, context); + break; + case "WEBHOOK": + await this.#sendWebhook(channel, payload, context); + break; + default: + assertNever(channel.type); + } + } catch (error) { + if (error instanceof SkipRetryError) { + logger.warn("[DeliverDashboardAgentWatchAlert] Skipping retry", { + watchId: payload.watchId, + channelId: channel.id, + reason: error.message, + }); + return; + } + throw error; + } + } + + async #sendEmail( + channel: ProjectAlertChannel, + payload: DashboardAgentWatchChannelAlertPayload, + context: ResolvedContext + ): Promise { + const emailProperties = ProjectAlertEmailProperties.safeParse(channel.properties); + if (!emailProperties.success) { + logger.error("[DeliverDashboardAgentWatchAlert] Failed to parse email properties", { + issues: emailProperties.error.issues, + }); + return; + } + + const token = await mintDashboardAgentAlertUnsubscribeToken({ + channelId: channel.id, + alertType: DASHBOARD_AGENT_WATCH_ALERT_TYPE, + }); + + await sendAlertEmail({ + email: "alert-dashboard-agent-watch", + to: emailProperties.data.email, + identity: payload.identity, + kind: payload.kind, + headline: presentAlert(payload).headline, + tone: presentAlert(payload).tone, + note: payload.note, + firedAt: payload.firedAt, + facts: factList(payload.facts), + dashboardLink: context.dashboardLink, + unsubscribeLink: `${env.APP_ORIGIN}/resources/dashboard-agent/alerts/${channel.id}/unsubscribe?token=${encodeURIComponent(token)}`, + organization: context.organizationTitle, + project: context.projectName, + environment: context.environmentName, + }); + } + + async #sendSlack( + channel: ProjectAlertChannel, + payload: DashboardAgentWatchChannelAlertPayload, + context: ResolvedContext + ): Promise { + const slackProperties = ProjectAlertSlackProperties.safeParse(channel.properties); + if (!slackProperties.success) { + logger.error("[DeliverDashboardAgentWatchAlert] Failed to parse slack properties", { + issues: slackProperties.error.issues, + }); + return; + } + + const integration = slackProperties.data.integrationId + ? await prisma.organizationIntegration.findFirst({ + where: { + id: slackProperties.data.integrationId, + organizationId: payload.organizationId, + }, + include: { tokenReference: true }, + }) + : await prisma.organizationIntegration.findFirst({ + where: { service: "SLACK", organizationId: payload.organizationId }, + orderBy: { createdAt: "desc" }, + include: { tokenReference: true }, + }); + + if (!integration || !isIntegrationForService(integration, "SLACK")) { + logger.error("[DeliverDashboardAgentWatchAlert] Slack integration not found"); + return; + } + + await this.#postSlackMessage(integration, { + channel: slackProperties.data.channelId, + ...this.#buildSlackMessage(payload, context), + } as ChatPostMessageArguments); + } + + async #sendWebhook( + channel: ProjectAlertChannel, + payload: DashboardAgentWatchChannelAlertPayload, + context: ResolvedContext + ): Promise { + const webhookProperties = ProjectAlertWebhookProperties.safeParse(channel.properties); + if (!webhookProperties.success) { + logger.error("[DeliverDashboardAgentWatchAlert] Failed to parse webhook properties", { + issues: webhookProperties.error.issues, + }); + return; + } + + const rawPayload = JSON.stringify({ + // Stable across attempts, so a receiver can dedupe a redelivery. This + // deliberately differs from the error-group webhook, which still mints a + // nanoid per attempt. + id: `watch:${payload.watchId}:channel:${payload.channelId}`, + created: new Date(payload.firedAt), + webhookVersion: WEBHOOK_VERSION, + type: "alert.dashboard_agent_watch", + object: { + watch: { + id: payload.watchId, + identity: payload.identity, + kind: payload.kind, + note: payload.note, + // `outcome` keeps its as-built two-value encoding for receivers that + // already parse it (§7.5); `resolution` and `observed` carry the model. + outcome: "fired", + resolution: payload.resolution ?? "condition_met", + observed: payload.observed ?? null, + firedAt: payload.firedAt, + facts: payload.facts, + }, + environment: { id: payload.environmentId, name: context.environmentName }, + organization: { + id: payload.organizationId, + slug: context.organizationSlug, + name: context.organizationTitle, + }, + project: { + id: payload.projectId, + ref: context.projectRef, + slug: context.projectSlug, + name: context.projectName, + }, + dashboardUrl: context.dashboardLink, + }, + }); + + const secret = await decryptSecret(env.ENCRYPTION_KEY, webhookProperties.data.secret); + const key = await subtle.importKey( + "raw", + Buffer.from(secret, "utf-8"), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await subtle.sign("HMAC", key, Buffer.from(rawPayload, "utf-8")); + + // Deliver via the SSRF-safe wrapper (see safeWebhookFetch.server.ts). + const response = await safeWebhookFetch(webhookProperties.data.url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trigger-signature-hmacsha256": Buffer.from(signature).toString("hex"), + }, + body: rawPayload, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + logger.info("[DeliverDashboardAgentWatchAlert] Failed to send webhook", { + status: response.status, + url: webhookProperties.data.url, + }); + throw new Error(`Failed to send watch alert webhook to ${webhookProperties.data.url}`); + } + } + + async #postSlackMessage( + integration: OrganizationIntegrationForService<"SLACK">, + message: ChatPostMessageArguments + ) { + const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration( + integration, + { forceBotToken: true } + ); + + try { + return await client.chat.postMessage({ + ...message, + unfurl_links: false, + unfurl_media: false, + }); + } catch (error) { + if (isWebAPIRateLimitedError(error)) { + throw new Error("Slack rate limited"); + } + if (isWebAPIPlatformError(error)) { + const code = (error as WebAPIPlatformError).data.error; + if (code === "invalid_blocks" || code === "account_inactive") { + throw new SkipRetryError(`Slack: ${code}`); + } + throw new Error("Slack platform error"); + } + throw error; + } + } + + #buildSlackMessage( + payload: DashboardAgentWatchChannelAlertPayload, + context: ResolvedContext + ): { text: string; blocks: object[] } { + const facts = factList(payload.facts); + const { headline } = presentAlert(payload); + + return { + text: `${headline} [${context.environmentName}]`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*${headline}* [${context.environmentName}]\nYou asked to be told when: ${payload.note}`, + }, + }, + ...(facts.length > 0 + ? [ + { + type: "section", + fields: facts.slice(0, 10).map((fact) => ({ + type: "mrkdwn", + text: `*${fact.label}:*\n${fact.value}`, + })), + }, + ] + : []), + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Open dashboard" }, + url: context.dashboardLink, + style: "primary", + }, + ], + }, + ], + }; + } +} + +/** + * The check's facts, flattened for display. The facts bag is per-watch-kind and + * open-ended, so this stays dumb on purpose: labelled scalars, nested values as + * compact JSON, and a cap so a big bag can't blow up an email or a Slack block. + */ +function factList(facts: Record): Array<{ label: string; value: string }> { + return Object.entries(facts) + .filter(([, value]) => value !== null && value !== undefined && value !== "") + .slice(0, 12) + .map(([key, value]) => ({ + label: humanizeFactKey(key), + value: typeof value === "object" ? JSON.stringify(value).slice(0, 200) : String(value), + })); +} + +function humanizeFactKey(key: string): string { + const spaced = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " "); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +function isWebAPIPlatformError(error: unknown): error is WebAPIPlatformError { + return (error as WebAPIPlatformError).code === ErrorCode.PlatformError; +} + +function isWebAPIRateLimitedError(error: unknown): error is WebAPIRateLimitedError { + return (error as WebAPIRateLimitedError).code === ErrorCode.RateLimitedError; +} diff --git a/apps/webapp/seed-agent-examples-chats.mts b/apps/webapp/seed-agent-examples-chats.mts index 0bcd0b8241e..a6ed89bdeb6 100644 --- a/apps/webapp/seed-agent-examples-chats.mts +++ b/apps/webapp/seed-agent-examples-chats.mts @@ -11,14 +11,16 @@ * * 1. Only what the production renderer handles survives: `text`, `reasoning`, * `tool-render_view` (real cards), `tool-get_report` (the report card) and - * `source-url`. Investigation cards, intent bubbles and prompt rows have no - * stored representation yet, so those beats become assistant + * `source-url`. Investigation cards, watch chips, intent bubbles and prompt + * rows have no stored representation yet, so those beats become assistant * text — and where a card carried the diagnosis, a real `diagnosis` view * block carries it instead. * 2. A landed tool call renders NOTHING. Completed `tool-*` parts are still * stored — they are what the answer was grounded on, and cards read them — * but no prose may point at one, because there is no row on screen to point * at. Only a failed call keeps a row, so a failure can still be seen. + * Likewise: a wake narration is an assistant message whose id is + * `wake:watch:{watchId}:{fired|expired}` — the id is what draws the banner. * 3. Chats that exist only to show a transient UI state (streaming text, a tool * mid-call, an unsent draft) are not ported: a stored transcript can't be * mid-flight, and storing one would render as a turn that never finishes. @@ -136,6 +138,19 @@ function assistant(slug: string, parts: unknown[]): SeedMessage { return { id: messageId(slug), role: "assistant", parts }; } +/** + * A wake narration: an assistant turn the watch started, not the user. + * + * The panel spots one by its message id — `wake:watch:{watchId}:{fired|expired}`, + * the same id the agent's `narrateWatchWake` writes — and draws the banner from + * that, so the parts are ordinary prose. The seeder creates no watch rows, so the + * banner takes its kind-agnostic wording ("Watch update — condition met") rather + * than colouring by watch kind. + */ +function wake(watchId: string, outcome: "fired" | "expired", parts: unknown[]): SeedMessage { + return { id: `wake:watch:${watchId}:${outcome}`, role: "assistant", parts }; +} + function text(value: string) { return { type: "text", text: value, state: "done" }; } @@ -581,6 +596,65 @@ Deployment: \`${deploymentUri}\`.` ], }); + // --- Watch (narration only — the chips are panel chrome) ---------------- + + chats.push({ + slug: "watch-created-and-wake", + title: "Tell me when the retry finishes", + minutesAgo: 7, + messages: [ + user("watch", "Tell me when the retry finishes."), + assistant("watch", [ + text( + `Watching \`${w.failedRunId}\` — I'll check every minute for up to 2 hours and tell you the moment it settles, whichever way it goes. I only speak once per watch, so it won't repeat itself.` + ), + ]), + wake(`watch_seed_${w.failedRunId}_finished`, "fired", [ + text( + `**The retry finished.** \`${w.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. + +That's this watch done. The rest of the ${w.firstFailureClock} burst is still queued behind \`${w.queue}\`'s concurrency limit, and the rate-limit error is worth watching for the next 12 hours in case the fix didn't take — say the word for either.` + ), + sourceUrl(runLink(w.failedRunId), `Run ${w.failedRunId}`), + ]), + ], + }); + + chats.push({ + slug: "watch-fired-and-expired", + title: "Watch the error and the backlog", + minutesAgo: 40, + messages: [ + user( + "watch-end", + `Watch two things for me: that 429 coming back, and \`${w.backlogQueue}\` draining.` + ), + assistant("watch-end", [ + text( + `Both armed. The error watch covers fingerprint \`${w.errorFingerprint}\` on \`${w.taskId}\` for the next 12 hours; the backlog watch covers \`${w.backlogQueue}\` for 6. Each one speaks once — when its condition is met, or when its window runs out with nothing to report.` + ), + ]), + wake(`watch_seed_${w.errorFingerprint}_recurrence`, "fired", [ + text( + `**The 429 is back.** \`${w.taskId}\` failed with the same fingerprint 9 minutes ago, so the retry change hasn't taken — the attempts are still landing inside one rate-limit window. + +That's the error watch spent. Cap \`${w.queue}\` at 20 concurrency if you want the burst to stop before the next deploy.` + ), + sourceUrl(failedRunsLink, `Failed ${w.taskId} runs`), + sourceUrl(queueLink, `Queue ${w.queue}`), + ]), + wake("watch_seed_backlog_drain", "expired", [ + text( + `**Six hours are up and \`${w.backlogQueue}\` never fully drained.** It's down from ${w.pending.toLocaleString( + "en-US" + )} to 610 pending, so it is clearing — just slower than the window I was given, which is why I have no answer rather than bad news. + +Ask again if you want another 6 hours.` + ), + ]), + ], + }); + // --- Reports ----------------------------------------------------------- chats.push({ diff --git a/apps/webapp/test/dashboardAgentWatchAlertFanout.test.ts b/apps/webapp/test/dashboardAgentWatchAlertFanout.test.ts new file mode 100644 index 00000000000..f5580c73860 --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatchAlertFanout.test.ts @@ -0,0 +1,219 @@ +// Watch alert fan-out: one delivery job per channel, so a retry can only ever +// re-send the channel that failed. +// +// Real service code, mocked IO only: the Prisma reads, the feature gate, the +// email transport, the Slack client and the webhook fetch. `~/v3/alertsWorker.server` +// is already stubbed globally by test/setup.ts, so the fan-out's enqueues land on +// a spy. +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type * as OrgIntegrationModule from "~/models/orgIntegration.server"; +import type * as SecretStoreModule from "~/services/secrets/secretStore.server"; + +const EMAIL_CHANNEL = { + id: "chan_email", + type: "EMAIL" as const, + properties: { email: "watcher@example.com" }, +}; +const SLACK_CHANNEL = { + id: "chan_slack", + type: "SLACK" as const, + properties: { channelId: "C123", channelName: "#alerts" }, +}; +const WEBHOOK_CHANNEL = { + id: "chan_webhook", + type: "WEBHOOK" as const, + properties: { + url: "https://example.com/hook", + secret: { nonce: "n", ciphertext: "c", tag: "t" }, + }, +}; + +const ctx = vi.hoisted(() => ({ + channels: [] as Array<{ id: string; type: string; properties: unknown }>, + gateAllowed: true, + /** Fails every webhook POST while set, to drive the retry. */ + webhookFails: false, +})); + +const sendAlertEmail = vi.hoisted(() => vi.fn(async () => undefined)); +const postMessage = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); +const safeWebhookFetch = vi.hoisted(() => + vi.fn(async (_url: string, _init: { body: string }) => ({ + ok: !ctx.webhookFails, + status: ctx.webhookFails ? 500 : 200, + })) +); + +vi.mock("~/db.server", () => { + const db = { + runtimeEnvironment: { + findFirst: async () => ({ + type: "PRODUCTION", + slug: "prod", + branchName: null, + project: { + name: "My Project", + slug: "my-project-abcd", + externalRef: "proj_abc", + organization: { slug: "acme", title: "Acme" }, + }, + }), + }, + projectAlertChannel: { + findMany: async () => ctx.channels, + findFirst: async ({ where }: { where: { id: string } }) => + ctx.channels.find((channel) => channel.id === where.id) ?? null, + }, + organizationIntegration: { + findFirst: async () => ({ + id: "int_1", + service: "SLACK", + organizationId: "org_1", + tokenReference: { provider: "DATABASE", key: "k" }, + }), + }, + }; + return { prisma: db, $replica: db, sqlDatabaseSchema: undefined }; +}); + +// The gate's one IO dependency, so the real gate code runs. +vi.mock("~/v3/canAccessDashboardAgent.server", () => ({ + canAccessDashboardAgent: async () => ctx.gateAllowed, +})); + +vi.mock("~/services/email.server", () => ({ sendAlertEmail })); +vi.mock("~/services/dashboardAgentAlertUnsubscribeToken.server", () => ({ + mintDashboardAgentAlertUnsubscribeToken: async () => "unsub-token", +})); +vi.mock("~/v3/services/alerts/safeWebhookFetch.server", () => ({ safeWebhookFetch })); + +vi.mock("~/services/secrets/secretStore.server", async (importOriginal) => ({ + ...(await importOriginal()), + decryptSecret: async () => "webhook-secret", +})); + +vi.mock("~/models/orgIntegration.server", async (importOriginal) => ({ + ...(await importOriginal()), + OrgIntegrationRepository: { + getAuthenticatedClientForIntegration: async () => ({ chat: { postMessage } }), + }, +})); + +const { DeliverDashboardAgentWatchAlertService, DeliverDashboardAgentWatchChannelAlertService } = + await import("~/v3/services/alerts/deliverDashboardAgentWatchAlert.server"); +const { alertsWorker } = await import("~/v3/alertsWorker.server"); + +const enqueue = alertsWorker.enqueue as unknown as ReturnType; + +const payload = { + watchId: "watch_1", + organizationId: "org_1", + projectId: "proj_1", + environmentId: "env_1", + userId: "user_1", + identity: "queue:my-queue", + kind: "queue_depth", + note: "the queue drains", + firedAt: "2026-07-30T10:00:00.000Z", + facts: { depth: 0 }, +}; + +beforeEach(() => { + ctx.channels = [EMAIL_CHANNEL, SLACK_CHANNEL, WEBHOOK_CHANNEL]; + ctx.gateAllowed = true; + ctx.webhookFails = false; + enqueue.mockClear(); + sendAlertEmail.mockClear(); + postMessage.mockClear(); + safeWebhookFetch.mockClear(); +}); + +describe("dashboard agent watch alert fan-out", () => { + test("enqueues one delivery job per channel, keyed per channel", async () => { + await new DeliverDashboardAgentWatchAlertService().call(payload); + + expect(enqueue).toHaveBeenCalledTimes(3); + const calls = enqueue.mock.calls.map(([arg]) => arg); + + expect(calls.map((call) => call.id)).toEqual([ + "watch-alert:watch_1:channel:chan_email", + "watch-alert:watch_1:channel:chan_slack", + "watch-alert:watch_1:channel:chan_webhook", + ]); + for (const call of calls) { + expect(call.job).toBe("v3.deliverDashboardAgentWatchAlertChannel"); + } + expect(calls[2].payload).toMatchObject({ ...payload, channelId: "chan_webhook" }); + + // The fan-out itself sends nothing. + expect(sendAlertEmail).not.toHaveBeenCalled(); + expect(postMessage).not.toHaveBeenCalled(); + expect(safeWebhookFetch).not.toHaveBeenCalled(); + }); + + test("the fan-out is idempotent: a retry re-enqueues the same job ids", async () => { + await new DeliverDashboardAgentWatchAlertService().call(payload); + const first = enqueue.mock.calls.map(([arg]) => arg.id); + enqueue.mockClear(); + + await new DeliverDashboardAgentWatchAlertService().call(payload); + expect(enqueue.mock.calls.map(([arg]) => arg.id)).toEqual(first); + }); + + test("a denied gate enqueues nothing", async () => { + ctx.gateAllowed = false; + await new DeliverDashboardAgentWatchAlertService().call(payload); + expect(enqueue).not.toHaveBeenCalled(); + }); +}); + +describe("dashboard agent watch alert per-channel delivery", () => { + test("a failing webhook retry re-sends only the webhook", async () => { + const service = new DeliverDashboardAgentWatchChannelAlertService(); + + // Email and Slack succeed on the first pass. + await service.call({ ...payload, channelId: "chan_email" }); + await service.call({ ...payload, channelId: "chan_slack" }); + expect(sendAlertEmail).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledTimes(1); + + // The webhook fails, then the job retries. + ctx.webhookFails = true; + await expect(service.call({ ...payload, channelId: "chan_webhook" })).rejects.toThrow( + /Failed to send watch alert webhook/ + ); + await expect(service.call({ ...payload, channelId: "chan_webhook" })).rejects.toThrow( + /Failed to send watch alert webhook/ + ); + + // Nothing else went out again. + expect(sendAlertEmail).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(safeWebhookFetch).toHaveBeenCalledTimes(2); + }); + + test("the webhook event id and created are stable across attempts", async () => { + const service = new DeliverDashboardAgentWatchChannelAlertService(); + ctx.webhookFails = true; + + await expect(service.call({ ...payload, channelId: "chan_webhook" })).rejects.toThrow(); + await expect(service.call({ ...payload, channelId: "chan_webhook" })).rejects.toThrow(); + + const bodies = safeWebhookFetch.mock.calls.map(([, init]) => JSON.parse(init.body)); + + expect(bodies).toHaveLength(2); + expect(bodies[0].id).toBe("watch:watch_1:channel:chan_webhook"); + expect(bodies[1].id).toBe(bodies[0].id); + expect(bodies[0].created).toBe(payload.firedAt); + expect(bodies[1].created).toBe(bodies[0].created); + }); + + test("an unsubscribed channel delivers nothing", async () => { + ctx.channels = []; + await new DeliverDashboardAgentWatchChannelAlertService().call({ + ...payload, + channelId: "chan_email", + }); + expect(sendAlertEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/webapp/test/dashboardAgentWatchChecks.test.ts b/apps/webapp/test/dashboardAgentWatchChecks.test.ts new file mode 100644 index 00000000000..926f3085fe4 --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatchChecks.test.ts @@ -0,0 +1,746 @@ +// The deterministic watch checks, driven through their REAL functions with plain +// fake readers injected (the waitingRunDiagnosis pattern — no mocks, no IO). +// +// What's pinned here is the four-valued contract: satisfied / pending / +// terminal_unsatisfied, and the rule that a broken data source is `unavailable` +// and never a verdict. Plus the wait LABEL, which must never call a +// time-from-creation a queue wait (VERDICTS.md §4). +import { describe, expect, it } from "vitest"; +import { + checkWatch, + type WatchCheckDeps, + type WatchErrorRecurrence, + type WatchRunRow, +} from "~/services/dashboardAgentWatchChecks"; +import type { WatchSpec } from "@internal/dashboard-agent-contracts"; + +const NOW = new Date("2026-07-27T12:00:00.000Z"); +const SINCE = new Date("2026-07-27T11:00:00.000Z"); + +function deps(overrides: Partial = {}): WatchCheckDeps { + return { + readRun: async () => null, + queueExists: async () => true, + readQueueDepth: async () => null, + readErrorRecurrence: async () => null, + readHealth: async () => null, + ...overrides, + }; +} + +function run(overrides: Partial = {}): WatchRunRow { + return { + friendlyId: "run_1", + status: "PENDING", + queue: "task/my-task", + createdAt: new Date("2026-07-27T11:55:00.000Z"), + queuedAt: null, + startedAt: null, + completedAt: null, + delayUntil: null, + ...overrides, + }; +} + +function check(spec: WatchSpec, d: WatchCheckDeps) { + return checkWatch(spec, d, { now: NOW, since: SINCE }); +} + +const runStart: WatchSpec = { + kind: "run_start", + runId: "run_1", + checkEveryMinutes: 1, + maxHours: 2, + note: "tell me when it starts", +}; + +const runFinished: WatchSpec = { + kind: "run_finished", + runId: "run_1", + checkEveryMinutes: 1, + maxHours: 2, + note: "tell me when it finishes", +}; + +const backlogDrain: WatchSpec = { + kind: "backlog_drain", + queue: "task/my-task", + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me when the backlog clears", +}; + +const errorRecurrence: WatchSpec = { + kind: "error_recurrence", + fingerprint: "fp_1", + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me if it comes back", +}; + +const healthRecovery: WatchSpec = { + kind: "health_recovery", + report: "health", + fromSeverity: "crit", + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me when prod is healthy", +}; + +describe("run_start", () => { + it("is satisfied once startedAt exists, whatever the current status is", async () => { + const outcome = await check( + runStart, + deps({ + readRun: async () => + run({ + status: "COMPLETED_WITH_ERRORS", + queuedAt: new Date("2026-07-27T11:56:00.000Z"), + startedAt: new Date("2026-07-27T11:58:00.000Z"), + }), + }) + ); + + expect(outcome.result).toBe("satisfied"); + // Queue wait = startedAt - queuedAt, labelled as a queue wait because + // queuedAt exists. + expect(outcome.facts.waitMs).toBe(2 * 60_000); + expect(outcome.facts.waitBasis).toBe("queued_at"); + expect(outcome.facts.waitLabel).toBe("queued for 2m"); + }); + + it("labels a wait with no queuedAt as time from creation, never as a queue wait", async () => { + const outcome = await check( + runStart, + deps({ readRun: async () => run({ status: "PENDING" }) }) + ); + + expect(outcome.result).toBe("pending"); + expect(outcome.facts.waitBasis).toBe("created_at"); + expect(outcome.facts.waitLabel).toBe("time from creation: 5m"); + expect(outcome.facts.queueWaitReliable).toBe(false); + }); + + // A resume/retry doesn't restamp queuedAt, so the leftover value is not this + // attempt's queue entry — it must not be measured from, let alone worded as + // queue latency. + it("does not measure a resumed run's wait from its stale queuedAt", async () => { + const outcome = await check( + runStart, + deps({ + readRun: async () => + run({ status: "WAITING_TO_RESUME", queuedAt: new Date("2026-07-27T11:50:00.000Z") }), + }) + ); + + expect(outcome.facts.queueWaitReliable).toBe(false); + expect(outcome.facts.waitBasis).toBe("created_at"); + // 11:55 -> 12:00, not 11:50 -> 12:00, and never called a queue wait. + expect(outcome.facts.waitMs).toBe(5 * 60_000); + expect(outcome.facts.waitLabel).toBe("waiting to resume; time from creation: 5m"); + }); + + it("says retry, not resume, for a run waiting on a retry", async () => { + const outcome = await check( + runStart, + deps({ + readRun: async () => + run({ + status: "RETRYING_AFTER_FAILURE", + queuedAt: new Date("2026-07-27T11:50:00.000Z"), + }), + }) + ); + + expect(outcome.facts.waitLabel).toBe("waiting to retry; time from creation: 5m"); + }); + + it("is terminal_unsatisfied when the run reached a terminal status without starting", async () => { + const outcome = await check( + runStart, + deps({ readRun: async () => run({ status: "CANCELED" }) }) + ); + + expect(outcome.result).toBe("terminal_unsatisfied"); + expect(outcome.facts.reason).toBe("never_started"); + }); + + it("is terminal_unsatisfied when the run is gone from the environment", async () => { + const outcome = await check(runStart, deps({ readRun: async () => null })); + expect(outcome.result).toBe("terminal_unsatisfied"); + expect(outcome.facts.reason).toBe("run_not_found"); + }); + + it("is unavailable — never a verdict — when the reader fails", async () => { + const outcome = await check( + runStart, + deps({ + readRun: async () => { + throw new Error("postgres is down"); + }, + }) + ); + + expect(outcome.result).toBe("unavailable"); + expect(outcome.facts.reason).toBe("check_failed"); + }); +}); + +describe("run_finished", () => { + it("is satisfied on a terminal status, with the outcome and execution duration", async () => { + const outcome = await check( + runFinished, + deps({ + readRun: async () => + run({ + status: "COMPLETED_SUCCESSFULLY", + queuedAt: new Date("2026-07-27T11:56:00.000Z"), + startedAt: new Date("2026-07-27T11:57:00.000Z"), + completedAt: new Date("2026-07-27T11:59:30.000Z"), + }), + }) + ); + + expect(outcome.result).toBe("satisfied"); + expect(outcome.facts.outcome).toBe("COMPLETED_SUCCESSFULLY"); + expect(outcome.facts.durationMs).toBe(150_000); + }); + + it("is pending while the run is still executing", async () => { + const outcome = await check( + runFinished, + deps({ + readRun: async () => + run({ status: "EXECUTING", startedAt: new Date("2026-07-27T11:58:00.000Z") }), + }) + ); + + expect(outcome.result).toBe("pending"); + expect(outcome.facts.durationMs).toBeNull(); + }); +}); + +describe("backlog_drain", () => { + it("is satisfied at depth 0", async () => { + const outcome = await check( + backlogDrain, + deps({ readQueueDepth: async () => ({ depth: 0, source: "live_queue", current: true }) }) + ); + + expect(outcome.result).toBe("satisfied"); + expect(outcome.facts).toMatchObject({ depth: 0, depthSource: "live_queue" }); + }); + + it("is pending while runs are still queued", async () => { + const outcome = await check( + backlogDrain, + deps({ + readQueueDepth: async () => ({ depth: 42, source: "queue_metrics", current: true }), + }) + ); + + expect(outcome.result).toBe("pending"); + expect(outcome.facts.depth).toBe(42); + }); + + // The one mistake this watch must never make: an analytics bucket that was + // empty minutes ago says nothing about the runs queued since. + it("is unavailable — never drained — when a zero comes from a stale bucket", async () => { + const asOf = new Date("2026-07-27T11:50:00.000Z"); + const outcome = await check( + backlogDrain, + deps({ + readQueueDepth: async () => ({ + depth: 0, + source: "queue_metrics", + current: false, + asOf, + }), + }) + ); + + expect(outcome.result).toBe("unavailable"); + expect(outcome.facts).toMatchObject({ + reason: "depth_stale", + depth: 0, + depthAsOf: asOf.toISOString(), + depthApproximate: true, + }); + }); + + // A stale non-zero depth is still evidence the queue wasn't empty — reported, + // and marked as the approximation it is. + it("stays pending on a stale non-zero depth, marked approximate", async () => { + const outcome = await check( + backlogDrain, + deps({ + readQueueDepth: async () => ({ + depth: 7, + source: "queue_metrics", + current: false, + asOf: new Date("2026-07-27T11:50:00.000Z"), + }), + }) + ); + + expect(outcome.result).toBe("pending"); + expect(outcome.facts).toMatchObject({ depth: 7, depthApproximate: true }); + }); + + it("is terminal_unsatisfied when the queue doesn't exist", async () => { + const outcome = await check( + backlogDrain, + deps({ readQueueDepth: async () => null, queueExists: async () => false }) + ); + + expect(outcome.result).toBe("terminal_unsatisfied"); + expect(outcome.facts.reason).toBe("queue_not_found"); + }); + + it("is unavailable when the queue exists but its depth can't be read", async () => { + const outcome = await check( + backlogDrain, + deps({ readQueueDepth: async () => null, queueExists: async () => true }) + ); + + expect(outcome.result).toBe("unavailable"); + expect(outcome.facts.reason).toBe("depth_unavailable"); + }); + + it("is unavailable when the depth reader throws", async () => { + const outcome = await check( + backlogDrain, + deps({ + readQueueDepth: async () => { + throw new Error("clickhouse timeout"); + }, + }) + ); + + expect(outcome.result).toBe("unavailable"); + }); +}); + +function recurrence(overrides: Partial = {}): WatchErrorRecurrence { + return { + occurredAt: new Date("2026-07-27T11:30:00.000Z"), + occurredAtPrecision: "minute", + countSince: 3, + countApproximate: false, + lastSeenAt: new Date("2026-07-27T11:45:00.000Z"), + ...overrides, + }; +} + +describe("error_recurrence", () => { + it("is satisfied on the first occurrence after `since`", async () => { + const outcome = await check( + errorRecurrence, + deps({ readErrorRecurrence: async () => recurrence() }) + ); + + expect(outcome.result).toBe("satisfied"); + expect(outcome.facts).toMatchObject({ + occurredAt: "2026-07-27T11:30:00.000Z", + occurredAtPrecision: "minute", + countSince: 3, + countApproximate: false, + since: SINCE.toISOString(), + }); + }); + + // The creation-minute case: the count can't be split, so it's a lower bound and + // the facts say so rather than quoting a number the data can't support. + it("carries the precision of an occurrence in the watch's creation minute", async () => { + const occurredAt = new Date("2026-07-27T11:00:40.000Z"); + const outcome = await check( + errorRecurrence, + deps({ + readErrorRecurrence: async () => + recurrence({ + occurredAt, + occurredAtPrecision: "exact", + countSince: 1, + countApproximate: true, + lastSeenAt: occurredAt, + }), + }) + ); + + expect(outcome.result).toBe("satisfied"); + expect(outcome.facts).toMatchObject({ + occurredAt: occurredAt.toISOString(), + occurredAtPrecision: "exact", + countSince: 1, + countApproximate: true, + }); + }); + + it("is pending when the error has never been seen at all", async () => { + const outcome = await check(errorRecurrence, deps({ readErrorRecurrence: async () => null })); + expect(outcome.result).toBe("pending"); + expect(outcome.facts).toMatchObject({ countSince: 0, lastSeenAt: null }); + }); + + // Seen before the watch, not since: still pending, and the facts carry when it + // was last seen so the narration can say that instead of just "nothing". + it("is pending when the error was last seen before `since`", async () => { + const lastSeenAt = new Date("2026-07-27T10:30:00.000Z"); + const outcome = await check( + errorRecurrence, + deps({ + readErrorRecurrence: async () => + recurrence({ occurredAt: null, occurredAtPrecision: null, countSince: 0, lastSeenAt }), + }) + ); + + expect(outcome.result).toBe("pending"); + expect(outcome.facts).toMatchObject({ countSince: 0, lastSeenAt: lastSeenAt.toISOString() }); + }); + + it("passes the watch's `since` to the reader, not the clock", async () => { + let seen: Date | undefined; + await check( + errorRecurrence, + deps({ + readErrorRecurrence: async (_fingerprint, since) => { + seen = since; + return null; + }, + }) + ); + expect(seen).toEqual(SINCE); + }); + + it("is unavailable when the reader throws", async () => { + const outcome = await check( + errorRecurrence, + deps({ + readErrorRecurrence: async () => { + throw new Error("clickhouse down"); + }, + }) + ); + expect(outcome.result).toBe("unavailable"); + }); + + // The model cites the API error id; ClickHouse stores the raw fingerprint. + it("strips the `error_` prefix before reading, and reports the raw fingerprint", async () => { + let seen: string | undefined; + const outcome = await check( + { ...errorRecurrence, fingerprint: "error_abc123" } as WatchSpec, + deps({ + readErrorRecurrence: async (fingerprint) => { + seen = fingerprint; + return null; + }, + }) + ); + + expect(seen).toBe("abc123"); + expect(outcome.facts.fingerprint).toBe("abc123"); + }); + + it("passes a raw fingerprint through unchanged", async () => { + let seen: string | undefined; + await check( + { ...errorRecurrence, fingerprint: "abc123" } as WatchSpec, + deps({ + readErrorRecurrence: async (fingerprint) => { + seen = fingerprint; + return null; + }, + }) + ); + + expect(seen).toBe("abc123"); + }); +}); + +describe("health_recovery", () => { + it("is satisfied when the report is trustworthy and ok", async () => { + const outcome = await check( + healthRecovery, + deps({ readHealth: async () => ({ trustworthy: true, severity: "ok" }) }) + ); + + expect(outcome.result).toBe("satisfied"); + expect(outcome.facts).toMatchObject({ severity: "ok", trustworthy: true }); + }); + + it("is pending while the report is still warn or crit", async () => { + for (const severity of ["warn", "crit"] as const) { + const outcome = await check( + healthRecovery, + deps({ readHealth: async () => ({ trustworthy: true, severity }) }) + ); + expect(outcome.result).toBe("pending"); + expect(outcome.facts.severity).toBe(severity); + } + }); + + it("NEVER fires recovery off an untrustworthy report, even when it says ok", async () => { + const outcome = await check( + healthRecovery, + deps({ readHealth: async () => ({ trustworthy: false, severity: "ok" }) }) + ); + + expect(outcome.result).toBe("pending"); + expect(outcome.facts).toMatchObject({ trustworthy: false, reason: "untrustworthy" }); + }); + + it("is unavailable when the report can't be produced", async () => { + const outcome = await check(healthRecovery, deps({ readHealth: async () => null })); + expect(outcome.result).toBe("unavailable"); + expect(outcome.facts.reason).toBe("report_unavailable"); + }); +}); + +// --------------------------------------------------------------------------- +// The observed outcome — the second half of a resolved result (§4.2). +// +// The resolution says HOW a watch ended; the observation says what was true when +// it did. These pin the observations the presentation actually splits on. +// --------------------------------------------------------------------------- + +const queueAbove: WatchSpec = { + kind: "queue_depth_above", + queue: "email-sends", + threshold: 500, + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me if it grows past 500", +}; + +describe("run_finished — status awareness", () => { + // The reader PRESERVES the final status. Without it "finished" and "failed" + // are the same `condition_met` and the banner cannot tell them apart. + it("keeps the final status on a completion, whatever it was", async () => { + for (const status of ["COMPLETED_SUCCESSFULLY", "COMPLETED_WITH_ERRORS", "CRASHED"]) { + const outcome = await check( + runFinished, + deps({ + readRun: async () => + run({ + status, + startedAt: new Date("2026-07-27T11:56:00.000Z"), + completedAt: new Date("2026-07-27T11:59:00.000Z"), + }), + }) + ); + expect(outcome.result).toBe("satisfied"); + expect(outcome.observed).toMatchObject({ + kind: "run_finished", + verified: true, + finalStatus: status, + durationMs: 180_000, + }); + } + }); + + it("never claims a final status for a run that is still going", async () => { + const outcome = await check( + runFinished, + deps({ readRun: async () => run({ status: "EXECUTING" }) }) + ); + expect(outcome.result).toBe("pending"); + expect(outcome.observed).toMatchObject({ kind: "run_finished", finalStatus: null }); + }); + + it("observes nothing verifiable when the run is gone", async () => { + const outcome = await check(runFinished, deps({ readRun: async () => null })); + expect(outcome.result).toBe("terminal_unsatisfied"); + expect(outcome.observed).toMatchObject({ kind: "run_finished", finalStatus: null }); + }); +}); + +describe("run_failed", () => { + const runFailed: WatchSpec = { + kind: "run_failed", + runId: "run_1", + checkEveryMinutes: 1, + maxHours: 2, + note: "tell me if it fails", + }; + + const finished = (status: string) => + run({ + status, + startedAt: new Date("2026-07-27T11:56:00.000Z"), + completedAt: new Date("2026-07-27T11:59:00.000Z"), + }); + + it("is satisfied by a failing terminal status", async () => { + for (const status of ["COMPLETED_WITH_ERRORS", "CRASHED", "SYSTEM_FAILURE", "TIMED_OUT"]) { + const outcome = await check(runFailed, deps({ readRun: async () => finished(status) })); + expect(outcome.result).toBe("satisfied"); + expect(outcome.observed).toMatchObject({ + kind: "run_failed", + verified: true, + finalStatus: status, + durationMs: 180_000, + }); + } + }); + + // The asymmetry that makes this a separate kind: a success is not merely "not + // yet", it means the condition can NEVER become true — and the mapping presents + // that as good news rather than as a watch that ran out of road. + it("becomes impossible — not pending — once the run succeeds", async () => { + const outcome = await check( + runFailed, + deps({ readRun: async () => finished("COMPLETED_SUCCESSFULLY") }) + ); + expect(outcome.result).toBe("terminal_unsatisfied"); + expect(outcome.observed).toMatchObject({ finalStatus: "COMPLETED_SUCCESSFULLY" }); + }); + + it("treats a cancellation as terminal too — it will not fail now", async () => { + const outcome = await check(runFailed, deps({ readRun: async () => finished("CANCELED") })); + expect(outcome.result).toBe("terminal_unsatisfied"); + }); + + it("keeps waiting while the run is still going, with no verdict on the row", async () => { + const outcome = await check( + runFailed, + deps({ readRun: async () => run({ status: "EXECUTING" }) }) + ); + expect(outcome.result).toBe("pending"); + expect(outcome.observed).toMatchObject({ kind: "run_failed", finalStatus: null }); + }); + + it("is unavailable, never a verdict, when the reader throws", async () => { + const outcome = await check( + runFailed, + deps({ + readRun: async () => { + throw new Error("postgres is down"); + }, + }) + ); + expect(outcome.result).toBe("unavailable"); + expect(outcome.observed).toMatchObject({ kind: "run_failed", verified: false }); + }); +}); + +describe("queue_depth_above", () => { + it("is the drain check with the comparison inverted", async () => { + const above = await check( + queueAbove, + deps({ readQueueDepth: async () => ({ depth: 612, source: "live_queue", current: true }) }) + ); + expect(above.result).toBe("satisfied"); + expect(above.observed).toMatchObject({ + kind: "queue_depth_above", + verified: true, + depth: 612, + threshold: 500, + }); + + const below = await check( + queueAbove, + deps({ readQueueDepth: async () => ({ depth: 500, source: "live_queue", current: true }) }) + ); + // Exactly AT the threshold is not above it. + expect(below.result).toBe("pending"); + }); + + // A quiet queue can grow at any moment — that is what this watch is for. Only + // the queue disappearing makes the condition impossible. + it("stays pending on an empty queue and is terminal only when the queue is gone", async () => { + const empty = await check( + queueAbove, + deps({ readQueueDepth: async () => ({ depth: 0, source: "live_queue", current: true }) }) + ); + expect(empty.result).toBe("pending"); + + const gone = await check( + queueAbove, + deps({ readQueueDepth: async () => null, queueExists: async () => false }) + ); + expect(gone.result).toBe("terminal_unsatisfied"); + }); + + // The freshness fence is shared verbatim with backlog_drain: a stale empty + // bucket can no more prove "still below" than it can prove "drained". + it("refuses a stale zero, and marks a stale non-zero approximate", async () => { + const stale = await check( + queueAbove, + deps({ + readQueueDepth: async () => ({ + depth: 0, + source: "queue_metrics", + current: false, + asOf: new Date("2026-07-27T11:40:00.000Z"), + }), + }) + ); + expect(stale.result).toBe("unavailable"); + expect(stale.observed).toMatchObject({ kind: "queue_depth_above", verified: false }); + + const staleAbove = await check( + queueAbove, + deps({ + readQueueDepth: async () => ({ + depth: 900, + source: "queue_metrics", + current: false, + asOf: new Date("2026-07-27T11:40:00.000Z"), + }), + }) + ); + expect(staleAbove.result).toBe("satisfied"); + expect(staleAbove.facts).toMatchObject({ depthApproximate: true, threshold: 500 }); + }); + + it("reports the depth it read, so the headline needs no second look", async () => { + const outcome = await check( + queueAbove, + deps({ readQueueDepth: async () => ({ depth: 612, source: "live_queue", current: true }) }) + ); + expect(outcome.observed).toMatchObject({ depth: 612, threshold: 500 }); + }); +}); + +describe("observations", () => { + it("marks the observation unverified when a reader throws", async () => { + const outcome = await check( + runFinished, + deps({ + readRun: async () => { + throw new Error("postgres is down"); + }, + }) + ); + expect(outcome.result).toBe("unavailable"); + expect(outcome.observed).toMatchObject({ kind: "run_finished", verified: false }); + }); + + it("never records a severity off an untrustworthy health report", async () => { + const outcome = await check( + healthRecovery, + deps({ readHealth: async () => ({ trustworthy: false, severity: "ok" }) }) + ); + expect(outcome.result).toBe("pending"); + expect(outcome.observed).toMatchObject({ + kind: "health_recovery", + verified: false, + severity: null, + }); + }); + + it("gives every kind an observation of its own kind", async () => { + const specs: WatchSpec[] = [ + runStart, + runFinished, + backlogDrain, + queueAbove, + errorRecurrence, + healthRecovery, + ]; + for (const spec of specs) { + const outcome = await check(spec, deps()); + expect(outcome.observed.kind).toBe(spec.kind); + } + }); +}); diff --git a/apps/webapp/test/dashboardAgentWatchToken.test.ts b/apps/webapp/test/dashboardAgentWatchToken.test.ts new file mode 100644 index 00000000000..31e3afc89ff --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatchToken.test.ts @@ -0,0 +1,111 @@ +// Watch tokens vs user-actor tokens. Both are HS256-signed with the same platform +// secret, so the ONLY thing keeping them apart is the token grammar — that's what +// these tests pin, from both directions and with the prefixes swapped by hand. +import { signUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac"; +import { describe, expect, it } from "vitest"; +import { + WATCH_TOKEN_GRACE_MS, + WATCH_TOKEN_PREFIX, + isDashboardAgentWatchToken, + signDashboardAgentWatchToken, + verifyDashboardAgentWatchToken, +} from "~/services/dashboardAgentWatchToken.server"; + +const SECRET = "test-session-secret-for-watch-tokens"; +const USER_ACTOR_PREFIX = "tr_uat_"; + +function inAnHour(): Date { + return new Date(Date.now() + 60 * 60 * 1000); +} + +describe("dashboard agent watch tokens", () => { + it("round-trips the watch id", async () => { + const expiresAt = inAnHour(); + const token = await signDashboardAgentWatchToken(SECRET, { watchId: "watch_abc", expiresAt }); + + expect(isDashboardAgentWatchToken(token)).toBe(true); + + const claims = await verifyDashboardAgentWatchToken(SECRET, token); + expect(claims?.watchId).toBe("watch_abc"); + // exp = expiresAt + the grace window, to the second. + expect(claims?.expiresAtSeconds).toBe( + Math.floor((expiresAt.getTime() + WATCH_TOKEN_GRACE_MS) / 1000) + ); + }); + + it("is deterministic, so the scheduler can re-mint instead of storing it", async () => { + const expiresAt = inAnHour(); + const a = await signDashboardAgentWatchToken(SECRET, { watchId: "watch_abc", expiresAt }); + const b = await signDashboardAgentWatchToken(SECRET, { watchId: "watch_abc", expiresAt }); + expect(a).toBe(b); + }); + + it("rejects another secret's signature", async () => { + const token = await signDashboardAgentWatchToken(SECRET, { + watchId: "watch_abc", + expiresAt: inAnHour(), + }); + expect(await verifyDashboardAgentWatchToken("a-different-secret", token)).toBeUndefined(); + }); + + it("stays valid through the grace window and dies after it", async () => { + // expiresAt just passed: the token still verifies, because the final (expiry) + // check happens after the deadline. + const justExpired = new Date(Date.now() - 60_000); + const graceful = await signDashboardAgentWatchToken(SECRET, { + watchId: "watch_abc", + expiresAt: justExpired, + }); + expect(await verifyDashboardAgentWatchToken(SECRET, graceful)).toMatchObject({ + watchId: "watch_abc", + }); + + // Past expiresAt + grace, nothing verifies. + const longGone = await signDashboardAgentWatchToken(SECRET, { + watchId: "watch_abc", + expiresAt: new Date(Date.now() - WATCH_TOKEN_GRACE_MS - 60_000), + }); + expect(await verifyDashboardAgentWatchToken(SECRET, longGone)).toBeUndefined(); + }); + + describe("cross-rejection with user-actor tokens", () => { + it("the UAT verifier rejects a watch token", async () => { + const watchToken = await signDashboardAgentWatchToken(SECRET, { + watchId: "watch_abc", + expiresAt: inAnHour(), + }); + expect(await verifyUserActorToken(SECRET, watchToken)).toBeUndefined(); + }); + + it("the watch verifier rejects a user-actor token", async () => { + const uat = await signUserActorToken(SECRET, { + userId: "user_1", + client: "dashboard-agent", + cap: ["read:runs"], + }); + expect(await verifyDashboardAgentWatchToken(SECRET, uat)).toBeUndefined(); + }); + + it("re-prefixing a UAT as a watch token doesn't help — the kind claim disagrees", async () => { + const uat = await signUserActorToken(SECRET, { + userId: "user_1", + client: "dashboard-agent-watch", + cap: ["read:runs"], + }); + const disguised = `${WATCH_TOKEN_PREFIX}${uat.slice(USER_ACTOR_PREFIX.length)}`; + + expect(isDashboardAgentWatchToken(disguised)).toBe(true); + expect(await verifyDashboardAgentWatchToken(SECRET, disguised)).toBeUndefined(); + }); + + it("re-prefixing a watch token as a UAT doesn't help either", async () => { + const watchToken = await signDashboardAgentWatchToken(SECRET, { + watchId: "watch_abc", + expiresAt: inAnHour(), + }); + const disguised = `${USER_ACTOR_PREFIX}${watchToken.slice(WATCH_TOKEN_PREFIX.length)}`; + + expect(await verifyUserActorToken(SECRET, disguised)).toBeUndefined(); + }); + }); +}); diff --git a/apps/webapp/test/dashboardAgentWatches.test.ts b/apps/webapp/test/dashboardAgentWatches.test.ts new file mode 100644 index 00000000000..85856706310 --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatches.test.ts @@ -0,0 +1,1769 @@ +// Watch creation + the private check endpoint, against a REAL Postgres. +// +// No fake datastore: the container runs both schemas — Prisma's (for the +// authorization query) and the dashboard-agent schema (for the watch rows) — so +// the guardrails under test are the real ones: the partial unique index behind +// dedup, the ≤3 limit, the `WHERE status = 'active'` transitions, and the +// membership-scoped authorization SQL. +// +// Only two things are injected rather than executed: the ClickHouse / run-queue +// readers (through the service's `checkDeps` seam) and the tick trigger +// (`scheduleTick`). +import { + appendChatMessage, + cancelWatch, + chatExists, + claimWatchDelivery, + claimWatchTick, + countUnreadWatchWakes, + createChat, + createDashboardAgentDb, + getChatMessages, + getWatch, + listActiveWatchesForChat, + listChatIdsWithUnreadWakes, + listUnreadWatchWakes, + markWatchDelivered, + recordWatchCheck, + releaseWatchDelivery, + transitionWatchCondition, + WATCH_DELIVERY_CLAIM_STALE_MS, + type DashboardAgentDb, + type DashboardAgentDbClient, + type Watch, +} from "@internal/dashboard-agent-db"; +import type { WatchSpec } 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, beforeEach, describe, expect, vi } from "vitest"; +import type { WatchCheckDeps, WatchRunRow } from "~/services/dashboardAgentWatchChecks"; + +// --- Holders, wired per test into the mocked singletons ---------------------- +const ctx = vi.hoisted(() => ({ + prisma: undefined as unknown as PrismaClient, + agentDb: undefined as unknown as DashboardAgentDb, + canAccess: true, + /** + * The delegated user-actor the create endpoint sees, if any. `environmentId` is + * the environment scope the dashboard minted the turn's token with — the + * authority the endpoint binds a watch to. + */ + actor: undefined as undefined | { userId: string; client?: string; environmentId?: string }, +})); + +// The shared UAT preamble. The create endpoint accepts ONLY a dashboard-agent +// user-actor token, so the tests drive the claims it would resolve. +vi.mock("~/services/uatRoutePreamble.server", () => ({ + authenticateUatOrApiRequest: async () => + ctx.actor + ? { + authenticationResult: { + type: "personalAccessToken", + result: { userId: ctx.actor.userId }, + }, + userActor: ctx.actor, + } + : undefined, +})); + +// `~/db.server` — the authorization query runs for real against the container. +vi.mock("~/db.server", () => { + const proxy = new Proxy( + {}, + { get: (_target, prop) => (ctx.prisma as unknown as Record)[prop as string] } + ); + return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined }; +}); + +// The agent store — a real Drizzle client on the same container. +vi.mock("~/services/dashboardAgentDb.server", () => ({ + get dashboardAgentDb() { + return ctx.agentDb; + }, +})); + +// The feature gate is a peripheral here; toggled per test to prove it's consulted. +vi.mock("~/v3/canAccessDashboardAgent.server", () => ({ + canAccessDashboardAgent: async () => ctx.canAccess, +})); + +// The check endpoint verifies watch tokens with `env.SESSION_SECRET`. Pin it here +// and hand the same string to the signer, so the suite never imports the env +// schema just to read one value. +const SESSION_SECRET = "test-session-secret-for-watch-tokens"; +process.env.SESSION_SECRET = SESSION_SECRET; + +const { + authorizeWatchEnvironment, + createDashboardAgentWatch, + deleteChatWithWatches, + listActiveWatchesForChats, +} = await import("~/services/dashboardAgentWatches.server"); +const { action: checkAction } = + await import("~/routes/api.v1.dashboard-agent.watches.$watchId.check"); +const { action: createAction } = await import("~/routes/api.v1.dashboard-agent.watches"); +const { sweepDashboardAgentWatches, WATCH_DELIVERY_GRACE_MS, WATCH_EXPIRY_GRACE_MS } = + await import("~/services/dashboardAgentWatchSweep.server"); +const { signDashboardAgentWatchToken } = await import("~/services/dashboardAgentWatchToken.server"); + +// --- Fixtures --------------------------------------------------------------- + +/** + * Apply the dashboard-agent schema by replaying its Drizzle migration SQL — + * every migration in the folder, in order, so a new migration can never leave + * this suite running against a stale schema (a fixed list once did). + */ +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; + +/** Both schemas live, both clients pointed at this test's database clone. */ +async function boot(prisma: PrismaClient, connectionUri: string) { + ctx.prisma = prisma; + await applyAgentSchema(prisma); + // A pool, not a single connection: the limit test fires concurrent creates and + // the advisory lock they serialize on only means anything across connections. + agentDbClient = createDashboardAgentDb(connectionUri, { max: 8 }); + 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>; + +/** The already-authorized environment the service takes. */ +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 = "chat_1") { + await createChat(ctx.agentDb, { + id: chatId, + organizationId: seeded.organization.id, + userId: seeded.user.id, + }); + return chatId; +} + +function runRow(overrides: Partial = {}): WatchRunRow { + return { + friendlyId: "run_1", + status: "PENDING", + queue: "task/my-task", + createdAt: new Date(), + queuedAt: null, + startedAt: null, + completedAt: null, + delayUntil: null, + ...overrides, + }; +} + +/** Injected readers. Defaults keep every condition pending with a live target. */ +function fakeCheckDeps(overrides: Partial = {}): WatchCheckDeps { + return { + readRun: async () => runRow(), + queueExists: async () => true, + readQueueDepth: async () => ({ depth: 7, source: "live_queue" }), + readErrorRecurrence: async () => null, + readHealth: async () => ({ trustworthy: true, severity: "warn" }), + ...overrides, + }; +} + +const RUN_START: WatchSpec = { + kind: "run_start", + runId: "run_1", + checkEveryMinutes: 1, + maxHours: 2, + note: "tell me when it starts", +}; + +const BACKLOG: WatchSpec = { + kind: "backlog_drain", + queue: "task/my-task", + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me when it drains", +}; + +/** Create a watch with the readers and the tick trigger injected. */ +/** + * A run that exists for the target validation and is gone by the time the + * immediate check reads it — the `condition_impossible` one-shot. + */ +function readRunOnce(first: WatchRunRow) { + let calls = 0; + return async () => (calls++ === 0 ? first : null); +} + +function create(args: { + seeded: Seeded; + spec?: WatchSpec; + chatId?: string; + environmentId?: string; + investigateOnAttention?: boolean; + checkDeps?: Partial; + scheduled?: Array<{ watchId: string; token: string; tick: number }>; + onSchedule?: () => void; +}) { + const environment = authenticated(args.seeded); + return createDashboardAgentWatch({ + environment: args.environmentId ? { ...environment, id: args.environmentId } : environment, + userId: args.seeded.user.id, + chatId: args.chatId ?? "chat_1", + spec: args.spec ?? RUN_START, + investigateOnAttention: args.investigateOnAttention, + deps: { + configured: () => true, + checkDeps: () => fakeCheckDeps(args.checkDeps), + scheduleTick: async (params) => { + args.onSchedule?.(); + args.scheduled?.push({ + watchId: params.watchId, + token: params.token, + tick: params.tick, + }); + }, + }, + }); +} + +beforeEach(() => { + ctx.canAccess = true; + ctx.actor = undefined; +}); + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +describe("createDashboardAgentWatch", () => { + postgresTest( + "creates an active watch and schedules its first tick", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const scheduled: Array<{ watchId: string; token: string; tick: number }> = []; + const result = await create({ seeded, scheduled }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.status).toBe("active"); + expect(result.identity).toBe("run_start:run_1"); + expect(result.immediate).toBeUndefined(); + + // The first tick carries the GENERATION it will claim — `tickCount + 1` — + // so it can't be confused with a reschedule of the same generation. + expect(scheduled).toHaveLength(1); + expect(scheduled[0]!.watchId).toBe(result.watchId); + expect(scheduled[0]!.tick).toBe(1); + // The token travels in the payload; it is never stored on the row. + expect(scheduled[0]!.token.startsWith("tr_daw_")).toBe(true); + + const row = await getWatch(ctx.agentDb, { id: result.watchId }); + expect(row).toMatchObject({ + status: "active", + deliveryStatus: "not_required", + environmentId: seeded.environment.id, + projectId: seeded.project.id, + organizationId: seeded.organization.id, + userId: seeded.user.id, + tickCount: 0, + // Off unless the user asked for it, and the project's external ref is + // on the row so a wake can scope an investigation the way a turn does. + investigateOnAttention: false, + projectRef: seeded.project.externalRef, + }); + } + ); + + postgresTest( + "records the investigate-on-attention consent when the caller asks for it", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const result = await create({ seeded, investigateOnAttention: true }); + + expect(result.ok).toBe(true); + if (!result.ok || !result.watching) return; + const row = await getWatch(ctx.agentDb, { id: result.watchId }); + expect(row?.investigateOnAttention).toBe(true); + // It is an action flag, not identity: the dedup string is unchanged. + expect(result.identity).toBe("run_start:run_1"); + } + ); + + postgresTest( + "stamps a server-set `since` on an error_recurrence watch", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const before = Date.now(); + const result = await create({ + seeded, + spec: { + kind: "error_recurrence", + fingerprint: "fp_1", + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me if it comes back", + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const row = await getWatch(ctx.agentDb, { id: result.watchId }); + const since = (row?.spec as { since?: string } | undefined)?.since; + expect(since).toBeDefined(); + expect(new Date(since!).getTime()).toBeGreaterThanOrEqual(before - 1000); + } + ); + + // §2.2/§4.1: the immediate check answers the request outright, and the answer + // is a ONE-SHOT RESULT BLOCK — not a watch that resolves in the same breath. + postgresTest( + "answers with a one-shot result and writes no row when the condition already holds", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + let ticks = 0; + const result = await create({ + seeded, + checkDeps: { + readRun: async () => runRow({ status: "EXECUTING", startedAt: new Date() }), + }, + onSchedule: () => { + ticks += 1; + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok || result.watching) throw new Error("expected a one-shot result"); + expect(result.immediate.result).toBe("satisfied"); + // The status-aware observation travels with it, so the caller can word the + // block without going back to the source. + expect(result.immediate.observed).toMatchObject({ kind: "run_start", started: true }); + // Nothing to wait for, so no tick is scheduled at all. + expect(ticks).toBe(0); + + // The whole point: NO row. No chip, no delivery claim, and no wake that + // could tell the user the same thing a second time. + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + expect( + await listActiveWatchesForChats({ + chatIds: ["chat_1"], + organizationId: seeded.organization.id, + userId: seeded.user.id, + }) + ).toEqual({}); + } + ); + + postgresTest( + "answers with a one-shot result when the condition can no longer happen", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const result = await create({ + seeded, + // Validated as existing, then gone by the time the check reads it. + checkDeps: { readRun: readRunOnce(runRow({ status: "QUEUED" })) }, + }); + + expect(result.ok).toBe(true); + if (!result.ok || result.watching) throw new Error("expected a one-shot result"); + expect(result.immediate.result).toBe("terminal_unsatisfied"); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + // The guardrails come BEFORE the immediate check (§4.4), so the refusal does + // not depend on whether the condition happens to be true right now. + postgresTest( + "refuses a duplicate before running the immediate check", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const first = await create({ seeded }); + expect(first.ok).toBe(true); + + let checks = 0; + const second = await create({ + seeded, + checkDeps: { + readRun: async () => { + checks += 1; + return runRow({ status: "EXECUTING", startedAt: new Date() }); + }, + }, + }); + + expect(second).toMatchObject({ ok: false, code: "duplicate" }); + // One read for target validation, and no check after the refusal. + expect(checks).toBe(1); + } + ); + + postgresTest( + "cancels the row silently when the first tick can't be scheduled", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const result = await create({ + seeded, + onSchedule: () => { + throw new Error("no agent project"); + }, + }); + + expect(result).toMatchObject({ ok: false, code: "internal" }); + // Cancelled, not resolved: nobody evaluated the user's condition, so there + // is nothing to narrate — and a cancellation is always silent. + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + const rows = await ctx.prisma.$queryRawUnsafe< + { status: string; cancel_reason: string; delivery_status: string }[] + >( + `select status, cancel_reason, delivery_status + from trigger_dashboard_agent.watches where chat_id = 'chat_1'` + ); + expect(rows).toMatchObject([ + { + status: "cancelled", + cancel_reason: "scheduling_failed", + delivery_status: "not_required", + }, + ]); + } + ); + + postgresTest( + "rejects a target that doesn't exist, writing nothing", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const result = await create({ + seeded, + spec: BACKLOG, + checkDeps: { queueExists: async () => false }, + }); + + expect(result).toMatchObject({ ok: false, code: "invalid_target" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "dedups the same condition and allows it in another environment", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + const first = await create({ seeded }); + expect(first.ok).toBe(true); + + const second = await create({ seeded }); + expect(second).toMatchObject({ ok: false, code: "duplicate" }); + if (!second.ok && first.ok) expect(second.existingId).toBe(first.watchId); + + // A different environment is a different thing to watch, same spec or not. + const otherEnv = await prisma.runtimeEnvironment.create({ + data: { + slug: "stg", + type: "STAGING", + projectId: seeded.project.id, + organizationId: seeded.organization.id, + apiKey: `tr_stg_${seeded.project.slug}`, + pkApiKey: `pk_stg_${seeded.project.slug}`, + shortcode: `s${seeded.project.slug.slice(0, 6)}`, + }, + }); + const third = await create({ seeded, environmentId: otherEnv.id }); + expect(third.ok).toBe(true); + } + ); + + postgresTest( + "refuses a 4th active watch in the same chat", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "watch"); + await seedChat(seeded); + + for (const runId of ["run_1", "run_2", "run_3"]) { + const created = await create({ seeded, spec: { ...RUN_START, runId } }); + expect(created.ok).toBe(true); + } + + const fourth = await create({ seeded, spec: { ...RUN_START, runId: "run_4" } }); + expect(fourth).toMatchObject({ ok: false, code: "limit_reached" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(3); + } + ); + + postgresTest( + "holds the ≤3 limit against four concurrent creates", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "race"); + await seedChat(seeded); + + // Four DIFFERENT conditions, so dedup can't be what rejects any of them — + // only the count-then-insert guardrail can, and it has to hold even when all + // four read the count at the same time (each on its own pool connection). + const results = await Promise.all( + ["run_1", "run_2", "run_3", "run_4"].map((runId) => + create({ seeded, spec: { ...RUN_START, runId } }) + ) + ); + + expect(results.filter((result) => result.ok)).toHaveLength(3); + expect( + results.filter((result) => !result.ok && result.code === "limit_reached") + ).toHaveLength(1); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(3); + } + ); +}); + +// The agent-facing adapter. Only the refusals are driven through the route here: +// the happy path would trigger the real watcher task, which belongs to the +// service-level tests above. +describe("the createWatch endpoint's authorization", () => { + function post(body: unknown) { + return createAction({ + request: new Request("https://example.com/api/v1/dashboard-agent/watches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + params: {}, + context: {}, + }); + } + + const validBody = (chatId: string) => ({ spec: RUN_START, chatId }); + + postgresTest("401s without a delegated token", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const response = await post(validBody("chat_1")); + expect(response.status).toBe(401); + }); + + postgresTest("403s for any other client's token", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "adapter"); + ctx.actor = { userId: seeded.user.id, client: "cli", environmentId: seeded.environment.id }; + + const response = await post(validBody("chat_1")); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "forbidden_client" }); + }); + + postgresTest( + "refuses a chat the authenticated user doesn't own, writing nothing", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const owner = await seed(prisma, "owner"); + const stranger = await seed(prisma, "stranger"); + await createChat(ctx.agentDb, { + id: "chat_victim", + organizationId: owner.organization.id, + userId: owner.user.id, + }); + + ctx.actor = { + userId: stranger.user.id, + client: "dashboard-agent", + environmentId: stranger.environment.id, + }; + + const response = await post(validBody("chat_victim")); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ code: "chat_not_found" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_victim" })).toHaveLength( + 0 + ); + } + ); + + postgresTest( + "refuses a token with no environment scope", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "noscope"); + await seedChat(seeded, "chat_1"); + // A turn minted without an environment. There is nothing to fall back to + // that this endpoint would trust, so the watch is refused outright. + ctx.actor = { userId: seeded.user.id, client: "dashboard-agent" }; + + const response = await post(validBody("chat_1")); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "invalid_target" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "refuses a body naming a different environment than the token's", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "mismatch"); + const other = await seed(prisma, "othermismatch"); + await seedChat(seeded, "chat_1"); + ctx.actor = { + userId: seeded.user.id, + client: "dashboard-agent", + environmentId: seeded.environment.id, + }; + + const response = await post({ + ...validBody("chat_1"), + environmentId: other.environment.id, + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "environment_mismatch" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "binds to the token's environment, not the chat's stored context", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "binding"); + // A second project + environment in the SAME org, so the org cross-check + // can't be what refuses anything below. + const otherProject = await prisma.project.create({ + data: { + name: `${seeded.project.slug}_b`, + slug: `${seeded.project.slug}_b`, + organizationId: seeded.organization.id, + externalRef: `proj_${seeded.project.slug}_b`, + }, + }); + const otherEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: otherProject.id, + organizationId: seeded.organization.id, + apiKey: `tr_prod_${otherProject.slug}`, + pkApiKey: `pk_prod_${otherProject.slug}`, + shortcode: `b${otherProject.slug.slice(0, 6)}`, + }, + }); + + // The chat's stored snapshot names project/env A… + await createChat(ctx.agentDb, { + id: "chat_1", + organizationId: seeded.organization.id, + userId: seeded.user.id, + metadata: { + context: { + environmentId: seeded.environment.id, + projectRef: seeded.project.externalRef, + }, + }, + }); + // …and the current turn is in project/env B. + ctx.actor = { + userId: seeded.user.id, + client: "dashboard-agent", + environmentId: otherEnvironment.id, + }; + + // The snapshot's project is a mismatch against the token's environment. If + // the snapshot were the authority this request would have been accepted. + const response = await post({ + ...validBody("chat_1"), + projectRef: seeded.project.externalRef, + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "environment_mismatch" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "refuses an environment in another org than the chat's", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "crossorg"); + const other = await seed(prisma, "otherorg"); + // The user is a member of both orgs, so the environment authorizes — only the + // chat/environment org cross-check stands between them. + await prisma.orgMember.create({ + data: { + organizationId: other.organization.id, + userId: seeded.user.id, + role: "ADMIN", + }, + }); + await seedChat(seeded, "chat_1"); + + // A token minted in the other org's environment for a chat in this one. + ctx.actor = { + userId: seeded.user.id, + client: "dashboard-agent", + environmentId: other.environment.id, + }; + + const response = await post(validBody("chat_1")); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ code: "invalid_target" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); +}); + +describe("the chat cascade and the list view", () => { + postgresTest( + "deleting a chat soft-deletes it and cancels its active watches in one call", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "cascade"); + await seedChat(seeded, "chat_1"); + await seedChat(seeded, "chat_2"); + + const mine = await create({ seeded, chatId: "chat_1" }); + const theirs = await create({ seeded, chatId: "chat_2" }); + expect(mine.ok && theirs.ok).toBe(true); + if (!mine.ok || !theirs.ok) return; + + expect(await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id })).toEqual({ + deleted: true, + cancelledWatches: 1, + }); + + // The chat is gone and its watch went with it — neither half can land alone. + expect( + await chatExists(ctx.agentDb, { + chatId: "chat_1", + userId: seeded.user.id, + organizationId: seeded.organization.id, + }) + ).toBe(false); + expect(await getWatch(ctx.agentDb, { id: mine.watchId })).toMatchObject({ + status: "cancelled", + cancelReason: "chat_deleted", + deliveryStatus: "not_required", + }); + // Another chat's watch is untouched. + expect(await getWatch(ctx.agentDb, { id: theirs.watchId })).toMatchObject({ + status: "active", + }); + } + ); + + postgresTest( + "aggregates active watches per chat in one query", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "chips"); + await seedChat(seeded, "chat_1"); + await seedChat(seeded, "chat_2"); + + const a = await create({ seeded, chatId: "chat_1", spec: { ...RUN_START, runId: "run_1" } }); + const b = await create({ seeded, chatId: "chat_1", spec: { ...RUN_START, runId: "run_2" } }); + const c = await create({ seeded, chatId: "chat_2" }); + expect(a.ok && b.ok && c.ok).toBe(true); + + const byChat = await listActiveWatchesForChats({ + chatIds: ["chat_1", "chat_2", "chat_missing"], + organizationId: seeded.organization.id, + userId: seeded.user.id, + }); + expect(byChat.chat_1).toHaveLength(2); + expect(byChat.chat_2).toHaveLength(1); + expect(byChat.chat_missing).toBeUndefined(); + expect(byChat.chat_2![0]).toMatchObject({ + identity: "run_start:run_1", + status: "active", + kind: "run_start", + note: RUN_START.note, + }); + + // Terminal watches drop off the chips. + if (a.ok) await cancelWatch(ctx.agentDb, { id: a.watchId, reason: "user" }); + if (b.ok) await cancelWatch(ctx.agentDb, { id: b.watchId, reason: "user" }); + expect( + ( + await listActiveWatchesForChats({ + chatIds: ["chat_1"], + organizationId: seeded.organization.id, + userId: seeded.user.id, + }) + ).chat_1 + ).toBeUndefined(); + } + ); + + postgresTest("returns nothing for an empty chat list", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + expect( + await listActiveWatchesForChats({ chatIds: [], organizationId: "org_x", userId: "user_x" }) + ).toEqual({}); + }); +}); + +describe("unread watch wakes", () => { + postgresTest( + "only signals a wake once its delivery landed", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "unread"); + await seedChat(seeded, "chat_1"); + + const created = await create({ seeded, chatId: "chat_1" }); + expect(created.ok).toBe(true); + if (!created.ok) return; + + const scope = { organizationId: seeded.organization.id, userId: seeded.user.id }; + + // Terminal but undelivered: the chat has no message to open yet, so the + // launcher's dot and the toast must stay quiet. + if (!created.watching) throw new Error("expected a watch"); + await transitionWatchCondition(ctx.agentDb, { + id: created.watchId, + resolution: "condition_met", + }); + expect(await countUnreadWatchWakes(ctx.agentDb, scope)).toBe(0); + expect(await listUnreadWatchWakes(ctx.agentDb, scope)).toEqual([]); + expect(await listChatIdsWithUnreadWakes(ctx.agentDb, scope)).toEqual(new Set()); + + await markWatchDelivered(ctx.agentDb, { id: created.watchId }); + expect(await countUnreadWatchWakes(ctx.agentDb, scope)).toBe(1); + expect(await listUnreadWatchWakes(ctx.agentDb, scope)).toMatchObject([ + { watchId: created.watchId, chatId: "chat_1", outcome: "fired" }, + ]); + expect(await listChatIdsWithUnreadWakes(ctx.agentDb, scope)).toEqual(new Set(["chat_1"])); + } + ); +}); + +describe("authorizeWatchEnvironment", () => { + postgresTest( + "passes for a member and fails once membership is gone", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "auth"); + + const params = { + userId: seeded.user.id, + organizationId: seeded.organization.id, + projectId: seeded.project.id, + environmentId: seeded.environment.id, + }; + + expect((await authorizeWatchEnvironment(params)).ok).toBe(true); + + await prisma.orgMember.deleteMany({ where: { userId: seeded.user.id } }); + expect(await authorizeWatchEnvironment(params)).toEqual({ + ok: false, + reason: "access_revoked", + }); + } + ); + + postgresTest("fails when the feature gate is revoked", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "auth"); + ctx.canAccess = false; + + expect( + await authorizeWatchEnvironment({ + userId: seeded.user.id, + organizationId: seeded.organization.id, + projectId: seeded.project.id, + environmentId: seeded.environment.id, + }) + ).toEqual({ ok: false, reason: "access_revoked" }); + }); + + postgresTest( + "fails when the snapshot names a different project", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "auth"); + const other = await seed(prisma, "other"); + + expect( + await authorizeWatchEnvironment({ + userId: seeded.user.id, + organizationId: seeded.organization.id, + // A snapshot/row mismatch must never resolve. + projectId: other.project.id, + environmentId: seeded.environment.id, + }) + ).toEqual({ ok: false, reason: "access_revoked" }); + } + ); +}); + +// The backstop. Everything here runs against the REAL query layer — the point of +// these tests is the wiring: which rows the sweep can actually see, and that a +// finalization goes through the same authorization a tick's check does. +describe("the watch sweep", () => { + /** An overdue active watch: created normally, then backdated past its deadline. */ + async function overdueWatch(seeded: Seeded, chatId = "chat_1") { + const created = await create({ seeded, chatId }); + if (!created.ok) throw new Error("the watch wasn't created"); + await ctx.prisma.$executeRawUnsafe( + `update trigger_dashboard_agent.watches set expires_at = now() - interval '1 hour' where id = $1`, + created.watchId + ); + return created.watchId; + } + + /** The seams: real store, injected readers, and a recorded delivery. */ + function sweepDeps(args: { + seeded: Seeded; + checkDeps?: Partial; + revoked?: boolean; + now?: Date; + failDelivery?: boolean; + delivered: string[]; + }) { + return { + now: () => args.now ?? new Date(), + checkDeps: () => fakeCheckDeps(args.checkDeps), + authorize: async () => + args.revoked + ? ({ ok: false, reason: "access_revoked" } as const) + : ({ ok: true, environment: authenticated(args.seeded) } as const), + deliver: async (watch: Watch) => { + if (args.failDelivery) throw new Error("the delivery couldn't be scheduled"); + args.delivered.push(watch.id); + }, + configured: () => true, + }; + } + + postgresTest( + "runs the final check on an overdue watch and fires it at the buzzer", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const watchId = await overdueWatch(seeded); + + const delivered: string[] = []; + const result = await sweepDashboardAgentWatches( + sweepDeps({ + seeded, + delivered, + // The condition became true right at the deadline: the sweep's last check + // has to see it, exactly as the tick's final check would. + checkDeps: { + readRun: async () => runRow({ status: "EXECUTING", startedAt: new Date() }), + }, + }) + ); + + expect(result).toMatchObject({ overdue: 1, fired: 1, expired: 0, cancelled: 0, failed: 0 }); + expect(await getWatch(ctx.agentDb, { id: watchId })).toMatchObject({ + status: "fired", + deliveryStatus: "pending", + }); + // The wake is handed to the watcher task, which owns the session append. + expect(delivered).toEqual([watchId]); + } + ); + + postgresTest( + "expires an overdue watch the check says hasn't happened, as verified", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const watchId = await overdueWatch(seeded); + + const delivered: string[] = []; + const result = await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered })); + + expect(result).toMatchObject({ overdue: 1, expired: 1, failed: 0 }); + const row = await getWatch(ctx.agentDb, { id: watchId }); + expect(row).toMatchObject({ status: "expired", deliveryStatus: "pending" }); + // The check ran, so the narration may say the condition didn't happen. + expect(row?.lastResult).toMatchObject({ verified: true, reason: "not_met_by_expiry" }); + expect(delivered).toEqual([watchId]); + } + ); + + postgresTest( + "cancels an overdue watch whose user lost access, and never wakes the chat", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const watchId = await overdueWatch(seeded); + + const delivered: string[] = []; + const result = await sweepDashboardAgentWatches( + sweepDeps({ seeded, delivered, revoked: true }) + ); + + expect(result).toMatchObject({ overdue: 1, cancelled: 1, expired: 0, fired: 0, failed: 0 }); + expect(await getWatch(ctx.agentDb, { id: watchId })).toMatchObject({ + status: "cancelled", + cancelReason: "access_revoked", + // A cancellation is never narrated. + deliveryStatus: "not_required", + }); + expect(delivered).toEqual([]); + } + ); + + postgresTest( + "leaves a watch that is still inside its deadline alone", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const created = await create({ seeded }); + expect(created.ok).toBe(true); + + const delivered: string[] = []; + const result = await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered })); + + expect(result).toMatchObject({ overdue: 0, undelivered: 0 }); + expect(delivered).toEqual([]); + } + ); + + postgresTest( + "recovers a wake the delivery lost, through the real query, exactly once", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const watchId = await overdueWatch(seeded); + + // First sweep: the row is expired with the delivery owed, and scheduling the + // wake fails. The row is no longer `active`, so from here on only the + // delivery query can see it at all. + const delivered: string[] = []; + await expect( + sweepDashboardAgentWatches(sweepDeps({ seeded, delivered, failDelivery: true })) + ).rejects.toThrow(/failed on 1 watches/); + expect(await getWatch(ctx.agentDb, { id: watchId })).toMatchObject({ + status: "expired", + deliveryStatus: "pending", + }); + expect(delivered).toEqual([]); + + // The retry, once the delivery grace has passed. Nothing re-decides the + // outcome; the wake is simply handed over — and only once. + const later = new Date(Date.now() + WATCH_DELIVERY_GRACE_MS + 60_000); + const second = await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered, now: later })); + expect(second).toMatchObject({ undelivered: 1, redelivered: 1, failed: 0 }); + expect(delivered).toEqual([watchId]); + + // Delivered for real (the watcher task marks it), so a third sweep sees nothing. + await markWatchDelivered(ctx.agentDb, { id: watchId }); + const third = await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered, now: later })); + expect(third).toMatchObject({ undelivered: 0, redelivered: 0 }); + expect(delivered).toEqual([watchId]); + } + ); + + postgresTest( + "a deliverer that died mid-delivery is recovered, but a fresh claim is left alone", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const watchId = await overdueWatch(seeded); + + await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered: [] })); + + // The watcher claimed the wake and hasn't marked it delivered. The outcome is + // old enough to be past the delivery grace, but a fresh claim is somebody's to + // hold — the sweep must not take it away. + await ctx.prisma.$executeRawUnsafe( + `update trigger_dashboard_agent.watches + set delivery_status = 'delivering', + delivery_claimed_at = now(), + last_checked_at = now() - interval '1 hour' + where id = $1`, + watchId + ); + expect(await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered: [] }))).toMatchObject({ + undelivered: 0, + }); + + // The claim is stale: that deliverer died, and nothing else will ever pick the + // wake up — so the sweep does. + await ctx.prisma.$executeRawUnsafe( + `update trigger_dashboard_agent.watches + set delivery_claimed_at = now() - interval '1 hour' where id = $1`, + watchId + ); + const recovered: string[] = []; + expect( + await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered: recovered })) + ).toMatchObject({ undelivered: 1, redelivered: 1, failed: 0 }); + expect(recovered).toEqual([watchId]); + } + ); + + // Under the resolution model an immediate answer never becomes a row, so there + // is no "inline outcome" for the sweep to recover: the one-shot result block IS + // the complete delivery (§7.5). What the sweep still owns is the wake for a + // watch that actually ran and resolved — covered above. + postgresTest( + "leaves nothing owed for a request the immediate check already answered", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + + const created = await create({ + seeded, + checkDeps: { readRun: async () => runRow({ status: "EXECUTING", startedAt: new Date() }) }, + }); + expect(created.ok).toBe(true); + if (!created.ok || created.watching) throw new Error("expected a one-shot result"); + + const delivered: string[] = []; + const later = new Date(Date.now() + WATCH_DELIVERY_GRACE_MS + 60_000); + const result = await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered, now: later })); + + expect(result).toMatchObject({ overdue: 0, undelivered: 0, redelivered: 0 }); + expect(delivered).toEqual([]); + } + ); + + postgresTest( + "finalizes overdue watches even with no agent to deliver to, and delivers once it's back", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const watchId = await overdueWatch(seeded); + + // The configuration vanished after the watch was created (a rotated secret, a + // rollback). The row must still be finalized, or it holds a watch slot and + // blocks re-asking forever. + const delivered: string[] = []; + const unconfigured = await sweepDashboardAgentWatches({ + ...sweepDeps({ seeded, delivered }), + configured: () => false, + }); + + expect(unconfigured).toMatchObject({ + overdue: 1, + expired: 1, + deliveryDeferred: 1, + undelivered: 0, + redelivered: 0, + failed: 0, + }); + expect(delivered).toEqual([]); + expect(await getWatch(ctx.agentDb, { id: watchId })).toMatchObject({ + status: "expired", + deliveryStatus: "pending", + }); + + // Configured again: the outcome the user was promised is handed over. + const later = new Date(Date.now() + WATCH_DELIVERY_GRACE_MS + 60_000); + const restored = await sweepDashboardAgentWatches( + sweepDeps({ seeded, delivered, now: later }) + ); + expect(restored).toMatchObject({ undelivered: 1, redelivered: 1, failed: 0 }); + expect(delivered).toEqual([watchId]); + } + ); + + postgresTest( + "the expiry grace keeps the sweep off a watch the tick chain is still finishing", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "sweep"); + await seedChat(seeded); + const created = await create({ seeded }); + expect(created.ok).toBe(true); + if (!created.ok) return; + + // A second past the deadline: the chain's own final check owns this window. + await ctx.prisma.$executeRawUnsafe( + `update trigger_dashboard_agent.watches set expires_at = now() - interval '1 second' where id = $1`, + created.watchId + ); + const delivered: string[] = []; + expect(await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered }))).toMatchObject({ + overdue: 0, + }); + + // Past the grace, the backstop takes over. + const later = new Date(Date.now() + WATCH_EXPIRY_GRACE_MS + 60_000); + expect( + await sweepDashboardAgentWatches(sweepDeps({ seeded, delivered, now: later })) + ).toMatchObject({ overdue: 1, expired: 1 }); + } + ); +}); + +describe("the tick claim", () => { + postgresTest( + "claiming a generation is not an observation: only a recorded check stamps one", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "claim"); + await seedChat(seeded); + const created = await create({ seeded }); + expect(created.ok).toBe(true); + if (!created.ok) return; + + // A claim whose check then never ran must leave no trace of an observation — + // the expiry narration reports `lastCheckedAt` as when the watch was last + // looked at, and a failed claim's timestamp would be a lie. + const claimed = await claimWatchTick(ctx.agentDb, { id: created.watchId, generation: 1 }); + expect(claimed).toMatchObject({ tickCount: 1, lastCheckedAt: null, lastResult: null }); + + // The check that did run writes the pair together. + await recordWatchCheck(ctx.agentDb, { id: created.watchId, lastResult: { pending: 4 } }); + const row = await getWatch(ctx.agentDb, { id: created.watchId }); + expect(row?.lastCheckedAt).toBeInstanceOf(Date); + expect(row?.lastResult).toMatchObject({ pending: 4 }); + // And the claim is still the only writer of the counter. + expect(row?.tickCount).toBe(1); + } + ); +}); + +// The delivery claim's fencing token. The status alone can't say WHOSE claim is in +// the row, and a deliverer that hangs past the stale window is taken over — so +// without the token its release would hand the new owner's claim back to `pending` +// (a third deliverer then appends in parallel with the second) and its late mark +// would close out a delivery it never made. +describe("the delivery claim", () => { + /** A resolved watch with its wake owed, and the current staleness cutoff. */ + async function firedWatch(seeded: Seeded) { + const created = await create({ seeded }); + expect(created.ok).toBe(true); + if (!created.ok) throw new Error("the watch wasn't created"); + const transitioned = await transitionWatchCondition(ctx.agentDb, { + id: created.watchId, + status: "fired", + lastResult: { result: "satisfied", facts: { verified: true } }, + }); + expect(transitioned).toMatchObject({ deliveryStatus: "pending" }); + return created.watchId; + } + + function staleBefore() { + return new Date(Date.now() - WATCH_DELIVERY_CLAIM_STALE_MS); + } + + /** Age the claim past the stale window, i.e. the deliverer that holds it died. */ + async function ageClaim(watchId: string) { + await ctx.prisma.$executeRawUnsafe( + `update trigger_dashboard_agent.watches + set delivery_claimed_at = now() - interval '1 hour' where id = $1`, + watchId + ); + } + + postgresTest( + "a stale takeover makes the old owner's release a no-op, and the new owner delivers once", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "claim-fence"); + await seedChat(seeded); + const watchId = await firedWatch(seeded); + + // A claims and hangs. + const a = await claimWatchDelivery(ctx.agentDb, { id: watchId, staleBefore: staleBefore() }); + expect(a).not.toBeNull(); + if (!a) return; + + // Five minutes later B takes the abandoned claim over, on a NEW token. + await ageClaim(watchId); + const b = await claimWatchDelivery(ctx.agentDb, { id: watchId, staleBefore: staleBefore() }); + expect(b).not.toBeNull(); + if (!b) return; + expect(b.claimId).not.toBe(a.claimId); + + // A wakes up, its append fails, and it releases — the claim it releases is no + // longer its own, so nothing moves. Without the token this would put the row + // back to `pending` while B is still appending. + expect( + await releaseWatchDelivery(ctx.agentDb, { id: watchId, claimId: a.claimId }) + ).toBeNull(); + expect(await getWatch(ctx.agentDb, { id: watchId })).toMatchObject({ + deliveryStatus: "delivering", + deliveryClaimId: b.claimId, + }); + + // So C finds a claim that is fresh and somebody's to hold, and delivers nothing. + expect( + await claimWatchDelivery(ctx.agentDb, { id: watchId, staleBefore: staleBefore() }) + ).toBeNull(); + + // B is the only deliverer, and its mark closes the row out exactly once. + expect( + await markWatchDelivered(ctx.agentDb, { id: watchId, claimId: b.claimId }) + ).toMatchObject({ deliveryStatus: "delivered" }); + expect(await markWatchDelivered(ctx.agentDb, { id: watchId, claimId: b.claimId })).toBeNull(); + } + ); + + postgresTest( + "a late delivered-mark from the old owner completes nothing", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "claim-late"); + await seedChat(seeded); + const watchId = await firedWatch(seeded); + + const a = await claimWatchDelivery(ctx.agentDb, { id: watchId, staleBefore: staleBefore() }); + expect(a).not.toBeNull(); + if (!a) return; + await ageClaim(watchId); + const b = await claimWatchDelivery(ctx.agentDb, { id: watchId, staleBefore: staleBefore() }); + expect(b).not.toBeNull(); + if (!b) return; + + // A's append landed somewhere long ago (or never); either way its mark must not + // finish B's delivery, which would leave the wake never appended and the row + // closed. Nor may the unfenced mark — the inline path's — touch a live claim. + expect(await markWatchDelivered(ctx.agentDb, { id: watchId, claimId: a.claimId })).toBeNull(); + expect(await markWatchDelivered(ctx.agentDb, { id: watchId })).toBeNull(); + expect(await getWatch(ctx.agentDb, { id: watchId })).toMatchObject({ + deliveryStatus: "delivering", + deliveredAt: null, + }); + + // B still owns the delivery and can still complete it. + expect( + await markWatchDelivered(ctx.agentDb, { id: watchId, claimId: b.claimId }) + ).toMatchObject({ deliveryStatus: "delivered" }); + } + ); + + postgresTest( + "the inline path marks a pending delivery without a claim", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "claim-inline"); + await seedChat(seeded); + const watchId = await firedWatch(seeded); + + // The one caller that never claims: an outcome resolved inline with nothing to + // narrate later. It closes out an owed, unclaimed row — and only that. + expect(await markWatchDelivered(ctx.agentDb, { id: watchId })).toMatchObject({ + deliveryStatus: "delivered", + }); + expect(await markWatchDelivered(ctx.agentDb, { id: watchId })).toBeNull(); + // A delivered wake can't be re-claimed either. + expect( + await claimWatchDelivery(ctx.agentDb, { id: watchId, staleBefore: staleBefore() }) + ).toBeNull(); + } + ); +}); + +// The delete/create race. The chat lock is what makes these two orderings have the +// same outcome, and the invariant is one-sided: an active watch on a deleted chat +// has nowhere to deliver anything, so it must never exist. +describe("deleting a chat while a watch is being created", () => { + postgresTest("holds in both orders", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "race"); + + for (const deleteFirst of [true, false]) { + const chatId = `chat_${deleteFirst ? "del" : "add"}`; + await seedChat(seeded, chatId); + + const creating = () => create({ seeded, chatId }); + const deleting = () => deleteChatWithWatches({ chatId, userId: seeded.user.id }); + // Both orderings of the same interleave: whichever takes the lock first, the + // other must not be able to leave a live watch behind. + const [a, b] = deleteFirst + ? await Promise.all([deleting(), creating()]) + : await Promise.all([creating(), deleting()]); + expect(a).toBeDefined(); + expect(b).toBeDefined(); + + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId })).toEqual([]); + expect( + await chatExists(ctx.agentDb, { + chatId, + userId: seeded.user.id, + organizationId: seeded.organization.id, + }) + ).toBe(false); + } + }); + + postgresTest( + "refuses a create against an already-deleted chat", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "race"); + await seedChat(seeded); + await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id }); + + expect(await create({ seeded })).toMatchObject({ ok: false, code: "chat_not_found" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toEqual([]); + } + ); +}); + +describe("the check endpoint", () => { + function request(token: string, body: unknown = {}) { + return new Request("https://example.com/api/v1/dashboard-agent/watches/x/check", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + async function activeWatch(seeded: Seeded) { + const result = await create({ seeded }); + if (!result.ok) throw new Error(`watch not created: ${result.code}`); + return result; + } + + function tokenFor(watchId: string, expiresAt: Date) { + return signDashboardAgentWatchToken(SESSION_SECRET, { watchId, expiresAt }); + } + + postgresTest("401s on a bad token", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "check"); + await seedChat(seeded); + const watch = await activeWatch(seeded); + + const response = await checkAction({ + request: request("tr_daw_nonsense"), + params: { watchId: watch.watchId }, + context: {}, + }); + expect(response.status).toBe(401); + }); + + postgresTest("403s when the token names another watch", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "check"); + await seedChat(seeded); + const watch = await activeWatch(seeded); + const token = await tokenFor("watch_someone_else", watch.expiresAt); + + const response = await checkAction({ + request: request(token), + params: { watchId: watch.watchId }, + context: {}, + }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "watch_mismatch" }); + }); + + postgresTest("answers a check and records what it saw", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "check"); + await seedChat(seeded); + const watch = await activeWatch(seeded); + const token = await tokenFor(watch.watchId, watch.expiresAt); + + const response = await checkAction({ + request: request(token), + params: { watchId: watch.watchId }, + context: {}, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + // The REAL readers run on this path, and no such run exists in this + // environment — so the honest answer is that it can never start. + expect(body.result).toBe("terminal_unsatisfied"); + + const row = await getWatch(ctx.agentDb, { id: watch.watchId }); + expect(row?.lastCheckedAt).not.toBeNull(); + // The generation claim is the task's, not this endpoint's, so the counter is + // untouched here. + expect(row?.tickCount).toBe(0); + // Still active: the fire/expire transition belongs to the watcher task. + expect(row?.status).toBe("active"); + }); + + postgresTest( + "refuses an ordinary check after expiry but allows the final one in grace", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "check"); + await seedChat(seeded); + const watch = await activeWatch(seeded); + + // Backdate the deadline: the ROW is the authority on expiry. + await prisma.$executeRawUnsafe( + `update trigger_dashboard_agent.watches set expires_at = now() - interval '1 minute' where id = $1`, + watch.watchId + ); + + const token = await tokenFor(watch.watchId, watch.expiresAt); + + const refused = await checkAction({ + request: request(token, {}), + params: { watchId: watch.watchId }, + context: {}, + }); + expect(refused.status).toBe(403); + expect(await refused.json()).toMatchObject({ code: "expired" }); + + const allowed = await checkAction({ + request: request(token, { final: true }), + params: { watchId: watch.watchId }, + context: {}, + }); + expect(allowed.status).toBe(200); + } + ); + + postgresTest( + "cancels the watch on revoked access, without reading environment data", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "check"); + await seedChat(seeded); + const watch = await activeWatch(seeded); + const token = await tokenFor(watch.watchId, watch.expiresAt); + + // Membership gone -> the tick must observe nothing at all. + await prisma.orgMember.deleteMany({ where: { userId: seeded.user.id } }); + + const response = await checkAction({ + request: request(token), + params: { watchId: watch.watchId }, + context: {}, + }); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "access_revoked" }); + + const row = await getWatch(ctx.agentDb, { id: watch.watchId }); + expect(row).toMatchObject({ + status: "cancelled", + cancelReason: "access_revoked", + // Cancellation is never notified. + deliveryStatus: "not_required", + }); + // No check ran, so nothing was recorded. + expect(row?.tickCount).toBe(0); + expect(row?.lastResult).toBeNull(); + } + ); + + postgresTest("403s once the watch is terminal", async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "check"); + await seedChat(seeded); + const watch = await activeWatch(seeded); + const token = await tokenFor(watch.watchId, watch.expiresAt); + + await prisma.$executeRawUnsafe( + `update trigger_dashboard_agent.watches set status = 'cancelled' where id = $1`, + watch.watchId + ); + + const response = await checkAction({ + request: request(token), + params: { watchId: watch.watchId }, + context: {}, + }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ code: "cancelled" }); + }); +}); + +// The deterministic transcript append behind the configuration card's submit +// path (§2.2): the confirmation and the one-shot result block are host-decided +// facts, written with no turn and no LLM, so the append itself has to be atomic +// and owner-scoped. +describe("appendChatMessage", () => { + postgresTest( + "appends in order without rewriting the transcript", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "append"); + await seedChat(seeded); + + const first = { id: "watch-card:watch_1", role: "assistant", parts: [] }; + const second = { id: "watch-card:watch_2", role: "assistant", parts: [] }; + + expect( + await appendChatMessage(ctx.agentDb, { + chatId: "chat_1", + userId: seeded.user.id, + message: first, + }) + ).toBe(true); + await appendChatMessage(ctx.agentDb, { + chatId: "chat_1", + userId: seeded.user.id, + message: second, + }); + + const messages = await getChatMessages(ctx.agentDb, { + chatId: "chat_1", + userId: seeded.user.id, + }); + expect(messages).toEqual([first, second]); + } + ); + + postgresTest( + "appends nothing for a chat the caller doesn't own", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "append-owner"); + await seedChat(seeded); + + expect( + await appendChatMessage(ctx.agentDb, { + chatId: "chat_1", + userId: "user_someone_else", + message: { id: "watch-card:watch_1", role: "assistant", parts: [] }, + }) + ).toBe(false); + + const messages = await getChatMessages(ctx.agentDb, { + chatId: "chat_1", + userId: seeded.user.id, + }); + expect(messages).toEqual([]); + } + ); +}); + +// The card's second run condition, end to end through the real creation path. +describe("run_failed creation", () => { + const RUN_FAILED: WatchSpec = { + kind: "run_failed", + runId: "run_1", + checkEveryMinutes: 1, + maxHours: 2, + note: "tell me if it fails", + }; + + postgresTest( + "watches a running run and dedups against the finished variant separately", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "runfailed"); + await seedChat(seeded); + + const failed = await create({ + seeded, + spec: RUN_FAILED, + checkDeps: { readRun: async () => runRow({ status: "EXECUTING" }) }, + }); + expect(failed.ok).toBe(true); + if (!failed.ok || !failed.watching) return; + expect(failed.identity).toBe("run_failed:run_1"); + + // "When it finishes" and "if it fails" are two different questions about + // the same run, so the second one is not a duplicate of the first. + const finished = await create({ + seeded, + spec: { ...RUN_FAILED, kind: "run_finished" } as WatchSpec, + checkDeps: { readRun: async () => runRow({ status: "EXECUTING" }) }, + }); + expect(finished.ok).toBe(true); + if (!finished.ok || !finished.watching) return; + expect(finished.identity).toBe("run_finished:run_1"); + } + ); + + postgresTest( + "answers outright, with no watch row, once the run has succeeded", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "runfailed-done"); + await seedChat(seeded); + + const result = await create({ + seeded, + spec: RUN_FAILED, + checkDeps: { + readRun: async () => + runRow({ status: "COMPLETED_SUCCESSFULLY", completedAt: new Date() }), + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // The one-shot path: it can never fail now, so there is nothing to watch. + expect(result.watching).toBe(false); + if (result.watching) return; + expect(result.immediate.result).toBe("terminal_unsatisfied"); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toEqual([]); + } + ); +}); diff --git a/apps/webapp/test/seedAgentExamplesChats.test.ts b/apps/webapp/test/seedAgentExamplesChats.test.ts index bf57211aff3..2d6ec659e20 100644 --- a/apps/webapp/test/seedAgentExamplesChats.test.ts +++ b/apps/webapp/test/seedAgentExamplesChats.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { wakeRefFromMessageId } from "~/components/dashboard-agent/WakeBanner"; import { resolveTriggerUri, type TriggerUriScope } from "~/services/resolveTriggerUri.server"; import { buildAgentExampleChats, type AgentExamplesWorld } from "../seed-agent-examples-chats.mjs"; @@ -122,6 +123,32 @@ describe("agent example transcripts", () => { } }); + it("stores every wake narration under a wake message id", () => { + // A wake is spotted by its id, not by its parts, so an ordinary `msg_` id + // would render as an answer to a question nobody asked — no banner. + const wakes = chats.flatMap((chat) => + chat.messages + .map((message) => ({ chat: chat.slug, ref: wakeRefFromMessageId(message.id) })) + .filter((entry) => entry.ref !== null) + ); + expect(wakes.map((entry) => entry.ref!.outcome)).toEqual( + expect.arrayContaining(["fired", "expired"]) + ); + + // Only an assistant turn can be a wake, and every conversation is started by + // the user — an assistant message in first position has no turn to belong to. + for (const entry of wakes) { + const chat = chats.find((candidate) => candidate.slug === entry.chat)!; + const message = chat.messages.find( + (candidate) => wakeRefFromMessageId(candidate.id)?.watchId === entry.ref!.watchId + )!; + expect(message.role, `${entry.chat}/${message.id}`).toBe("assistant"); + } + for (const chat of chats) { + expect(chat.messages[0]!.role, `${chat.slug} opens`).toBe("user"); + } + }); + it("never points prose at UI the panel no longer renders", () => { // A landed tool call leaves no row, and text has no raw toggle — so prose // that says "see the call above" or "show raw" describes a panel that is gone. diff --git a/internal-packages/dashboard-agent-contracts/src/blocks.ts b/internal-packages/dashboard-agent-contracts/src/blocks.ts index 7354ebd9d7c..ddf63c64c9e 100644 --- a/internal-packages/dashboard-agent-contracts/src/blocks.ts +++ b/internal-packages/dashboard-agent-contracts/src/blocks.ts @@ -605,6 +605,8 @@ export const INVESTIGATION_CAPABILITIES_VERSION = 1; export const investigationActionKindSchema = z.enum([ /** Open the cited source location in the conversation. Concluded cards only. */ "show_code", + /** Watch for the same failure happening again. */ + "watch_recurrence", /** Go look at the other runs that hit this. */ "view_similar", /** Keep asking about this investigation. */ @@ -636,6 +638,53 @@ const investigationBlockBodyInputSchema = z.object({ investigation: investigationStateInputSchema, }); +// --------------------------------------------------------------------------- +// watch_result — what the configuration card leaves behind +// --------------------------------------------------------------------------- + +/** + * The persisted trace of a submitted watch card (§2.2, binding). Only a SUBMITTED + * outcome reaches the transcript, and it is one of two things: + * + * - `watching` — the confirmation block. A watch is running; it states the four + * lifetime facts (what · how often it checks · that it reports once · when it + * gives up) and there is no separate request line, because this block IS the + * transcript record. + * - `already_true` / `impossible` — the ONE-SHOT RESULT BLOCK. The immediate check + * answered the request outright, so no watch exists: no chip, no wake, nothing + * to cancel. + * + * The wording is FROZEN at append time, the same way a resolved watch's facts are + * (§7.5): the block carries final English rather than a key, so a later copy + * change never rewrites what a user was told. It is host-emitted only — absent + * from `viewBlockInputSchema`, exactly like `report`, so the model can neither + * fabricate a confirmation nor claim a watch that does not exist. + */ +export const watchResultOutcomeSchema = z.enum(["watching", "already_true", "impossible"]); +export type WatchResultOutcome = z.infer; + +const watchResultBlockBodySchema = z.object({ + type: z.literal("watch_result"), + outcome: watchResultOutcomeSchema, + /** The fact, first: "Watching email-sends until the queue drains." */ + headline: z.string(), + /** The lifetime sentence. Null on a one-shot result — nothing is watching. */ + lifetime: z.string().nullable().default(null), + /** An honest aside, e.g. that the creation-time check couldn't run (§4.1). */ + detail: z.string().nullable().default(null), + /** The follow-ups that were actually set up, one short line each. */ + followUp: z.array(z.string()).max(4).default([]), + /** The live watch this confirms, for the chip it pairs with. Null one-shot. */ + watchId: z.string().nullable().default(null), +}); + +export const watchResultBlockSchema = watchResultBlockBodySchema.merge(blockEnvelopeSchema); +export const legacyWatchResultBlockSchema = + watchResultBlockBodySchema.extend(optionalEnvelopeShape); + +export type EnvelopedWatchResultBlock = z.infer; +export type WatchResultBlock = z.infer; + // --------------------------------------------------------------------------- // Model-facing input schemas (no envelope) // --------------------------------------------------------------------------- @@ -699,6 +748,7 @@ export const viewBlockSchema = z.discriminatedUnion("type", [ chartBlockSchema, reportBlockSchema, investigationBlockSchema, + watchResultBlockSchema, ]); export type EnvelopedDiagnosisBlock = z.infer; @@ -723,6 +773,7 @@ export const legacyViewBlockSchema = z.discriminatedUnion("type", [ legacyChartBlockSchema, legacyReportBlockSchema, legacyInvestigationBlockSchema, + legacyWatchResultBlockSchema, ]); /** diff --git a/internal-packages/dashboard-agent-contracts/src/contracts.test.ts b/internal-packages/dashboard-agent-contracts/src/contracts.test.ts index dae401a0e57..c0e58b421a1 100644 --- a/internal-packages/dashboard-agent-contracts/src/contracts.test.ts +++ b/internal-packages/dashboard-agent-contracts/src/contracts.test.ts @@ -49,8 +49,20 @@ describe("intents", () => { expect(isExecutableIntent(parsed)).toBe(true); }); - it("parses ask", () => { + it("parses ask and watch", () => { expect(agentIntentSchema.safeParse({ kind: "ask", prompt: "why?" }).success).toBe(true); + expect( + agentIntentSchema.safeParse({ + kind: "watch", + spec: { + kind: "backlog_drain", + queue: "email-sends", + checkEveryMinutes: 5, + maxHours: 2, + note: "waiting for the backlog", + }, + }).success + ).toBe(true); }); it("keeps propose_fix in the wire format but marks it non-executable", () => { diff --git a/internal-packages/dashboard-agent-contracts/src/index.ts b/internal-packages/dashboard-agent-contracts/src/index.ts index 619f2be27b2..0cc3487f7fa 100644 --- a/internal-packages/dashboard-agent-contracts/src/index.ts +++ b/internal-packages/dashboard-agent-contracts/src/index.ts @@ -14,3 +14,4 @@ export * from "./page-context.js"; export * from "./run-filters.js"; export * from "./suggested-prompts.js"; export * from "./trigger-uri.js"; +export * from "./watch.js"; diff --git a/internal-packages/dashboard-agent-contracts/src/intent.ts b/internal-packages/dashboard-agent-contracts/src/intent.ts index 431aef22dde..1b3ba675d55 100644 --- a/internal-packages/dashboard-agent-contracts/src/intent.ts +++ b/internal-packages/dashboard-agent-contracts/src/intent.ts @@ -4,6 +4,7 @@ */ import { runFiltersSchema } from "./run-filters.js"; import { triggerUriSchema } from "./trigger-uri.js"; +import { watchSpecSchema } from "./watch.js"; import { z } from "zod"; export const agentIntentSchema = z.discriminatedUnion("kind", [ @@ -15,6 +16,8 @@ export const agentIntentSchema = z.discriminatedUnion("kind", [ }), /** Hand a follow-up question back into the conversation. */ z.object({ kind: z.literal("ask"), prompt: z.string() }), + /** Start watching a condition. */ + z.object({ kind: z.literal("watch"), spec: watchSpecSchema }), /** * RESERVED — DO NOT EMIT OR EXECUTE IN M0–M7. * diff --git a/internal-packages/dashboard-agent-contracts/src/suggested-prompts.ts b/internal-packages/dashboard-agent-contracts/src/suggested-prompts.ts index 79d2aba7685..05f4f954c0b 100644 --- a/internal-packages/dashboard-agent-contracts/src/suggested-prompts.ts +++ b/internal-packages/dashboard-agent-contracts/src/suggested-prompts.ts @@ -25,7 +25,7 @@ export type SuggestedPrompt = z.infer; /** * Never show more than this many chips at once: the promoted slot plus the four - * slots the host's resolver fills (investigate, explain, docs). + * slots the host's resolver fills (investigate, watch, explain, docs). */ export const SUGGESTED_PROMPT_CAP = 5; diff --git a/internal-packages/dashboard-agent-contracts/src/watch.test.ts b/internal-packages/dashboard-agent-contracts/src/watch.test.ts new file mode 100644 index 00000000000..41708d89b25 --- /dev/null +++ b/internal-packages/dashboard-agent-contracts/src/watch.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest"; +import { + WATCH_FAILED_RUN_STATUSES, + WATCH_KINDS, + resolveWatchResult, + watchCheckResultSchema, + watchDeliveryStatusSchema, + watchHeadlineKeys, + watchIdentity, + watchObservedOutcomeSchema, + watchResolutionSchema, + watchResolutionToWireStatus, + watchResolutions, + watchRunDisposition, + watchSpecSchema, + watchStatusSchema, + type WatchKind, + type WatchSpec, +} from "./watch.js"; + +const common = { maxHours: 6, note: "because I asked" }; + +const specs = { + run_start: { ...common, kind: "run_start", runId: "run_123", checkEveryMinutes: 1 }, + run_finished: { ...common, kind: "run_finished", runId: "run_x", checkEveryMinutes: 5 }, + run_failed: { ...common, kind: "run_failed", runId: "run_y", checkEveryMinutes: 5 }, + backlog_drain: { ...common, kind: "backlog_drain", queue: "email-sends", checkEveryMinutes: 5 }, + queue_depth_above: { + ...common, + kind: "queue_depth_above", + queue: "email-sends", + threshold: 500, + checkEveryMinutes: 5, + }, + error_recurrence: { + ...common, + kind: "error_recurrence", + fingerprint: "a1b2c3", + checkEveryMinutes: 15, + }, + health_recovery: { + ...common, + kind: "health_recovery", + report: "health", + fromSeverity: "warn", + checkEveryMinutes: 60, + }, +} satisfies Record; + +describe("watchSpecSchema", () => { + it("accepts every kind", () => { + for (const spec of Object.values(specs)) { + expect(watchSpecSchema.safeParse(spec).success).toBe(true); + } + }); + + it("allows a 1-minute cadence for run-state watches", () => { + expect(watchSpecSchema.safeParse({ ...specs.run_finished, checkEveryMinutes: 1 }).success).toBe( + true + ); + }); + + it("rejects a 1-minute cadence for aggregate watches", () => { + expect( + watchSpecSchema.safeParse({ ...specs.backlog_drain, checkEveryMinutes: 1 }).success + ).toBe(false); + expect( + watchSpecSchema.safeParse({ ...specs.error_recurrence, checkEveryMinutes: 1 }).success + ).toBe(false); + expect( + watchSpecSchema.safeParse({ ...specs.queue_depth_above, checkEveryMinutes: 1 }).success + ).toBe(false); + expect( + watchSpecSchema.safeParse({ ...specs.health_recovery, checkEveryMinutes: 1 }).success + ).toBe(false); + }); + + it("rejects an off-grid cadence", () => { + expect(watchSpecSchema.safeParse({ ...specs.run_start, checkEveryMinutes: 3 }).success).toBe( + false + ); + expect( + watchSpecSchema.safeParse({ ...specs.backlog_drain, checkEveryMinutes: 30 }).success + ).toBe(false); + }); + + it("enforces the 24 hour ceiling", () => { + expect(watchSpecSchema.safeParse({ ...specs.run_start, maxHours: 24 }).success).toBe(true); + expect(watchSpecSchema.safeParse({ ...specs.run_start, maxHours: 25 }).success).toBe(false); + expect(watchSpecSchema.safeParse({ ...specs.run_start, maxHours: 0 }).success).toBe(false); + }); + + it("requires a note", () => { + const { note, ...withoutNote } = specs.run_start; + expect(watchSpecSchema.safeParse(withoutNote).success).toBe(false); + }); + + it("does not accept a client-supplied `since` on error_recurrence", () => { + const parsed = watchSpecSchema.parse({ + ...specs.error_recurrence, + since: "2026-01-01T00:00:00.000Z", + }); + expect(parsed).not.toHaveProperty("since"); + }); + + it("rejects an unknown kind", () => { + expect(watchSpecSchema.safeParse({ ...common, kind: "run_slow", runId: "run_1" }).success).toBe( + false + ); + }); +}); + +describe("watchIdentity", () => { + it("identifies the condition, not the cadence", () => { + expect(watchIdentity(specs.run_start)).toBe("run_start:run_123"); + expect(watchIdentity(specs.run_finished)).toBe("run_finished:run_x"); + expect(watchIdentity(specs.run_failed)).toBe("run_failed:run_y"); + expect(watchIdentity(specs.backlog_drain)).toBe("backlog_drain:email-sends"); + expect(watchIdentity(specs.queue_depth_above)).toBe("queue_depth_above:email-sends:500"); + expect(watchIdentity(specs.error_recurrence)).toBe("error_recurrence:a1b2c3"); + expect(watchIdentity(specs.health_recovery)).toBe("health_recovery:health"); + }); + + it("ignores cadence, note, and maxHours", () => { + expect( + watchIdentity({ + ...specs.backlog_drain, + checkEveryMinutes: 60, + note: "different", + maxHours: 1, + }) + ).toBe(watchIdentity(specs.backlog_drain)); + }); + + it("covers every kind exhaustively", () => { + for (const kind of WATCH_KINDS) { + expect(watchIdentity(specs[kind])).toContain(`${kind}:`); + } + }); +}); + +// Compile-time exhaustiveness: adding a WatchSpec variant breaks this switch. +function describeWatch(spec: WatchSpec): string { + switch (spec.kind) { + case "run_start": + return `start of ${spec.runId}`; + case "run_finished": + return `finish of ${spec.runId}`; + case "run_failed": + return `failure of ${spec.runId}`; + case "backlog_drain": + return `drain of ${spec.queue}`; + case "queue_depth_above": + return `${spec.queue} above ${spec.threshold}`; + case "error_recurrence": + return `recurrence of ${spec.fingerprint}`; + case "health_recovery": + return `recovery from ${spec.fromSeverity}`; + default: { + const unreachable: never = spec; + throw new Error(`Unhandled: ${JSON.stringify(unreachable)}`); + } + } +} + +describe("exhaustiveness", () => { + it("handles every kind", () => { + expect(Object.values(specs).map(describeWatch)).toHaveLength(WATCH_KINDS.length); + expect(WATCH_KINDS).toHaveLength(7); + }); +}); + +describe("enums", () => { + it("check results", () => { + expect(watchCheckResultSchema.options).toEqual([ + "pending", + "satisfied", + "terminal_unsatisfied", + "unavailable", + ]); + }); + + it("statuses", () => { + expect(watchStatusSchema.options).toEqual(["active", "fired", "expired", "cancelled"]); + expect(watchDeliveryStatusSchema.options).toEqual(["not_required", "pending", "delivered"]); + }); +}); + +/* ------------------------------------------------------------------ * + * The resolution model + * ------------------------------------------------------------------ */ + +describe("queue_depth_above", () => { + it("requires a non-negative integer threshold", () => { + expect(watchSpecSchema.safeParse({ ...specs.queue_depth_above, threshold: 0 }).success).toBe( + true + ); + expect(watchSpecSchema.safeParse({ ...specs.queue_depth_above, threshold: -1 }).success).toBe( + false + ); + expect(watchSpecSchema.safeParse({ ...specs.queue_depth_above, threshold: 1.5 }).success).toBe( + false + ); + }); + + it("treats the threshold as part of the identity", () => { + expect(watchIdentity({ ...specs.queue_depth_above, threshold: 5000 })).not.toBe( + watchIdentity(specs.queue_depth_above) + ); + // …but not the cadence or the note, same as every other kind. + expect( + watchIdentity({ ...specs.queue_depth_above, checkEveryMinutes: 60, note: "other" }) + ).toBe(watchIdentity(specs.queue_depth_above)); + }); +}); + +describe("resolutions", () => { + it("has three values, and `unavailable` is not one of them", () => { + expect(watchResolutionSchema.options).toEqual([ + "condition_met", + "window_completed", + "condition_impossible", + ]); + expect(watchResolutions).not.toContain("unavailable"); + }); + + // §7.5 binding: the wire keeps its as-built two-value encoding, so persisted + // wake ids, delivery ids and banner render keys stay valid. + it("encodes onto the stable two-value wire status", () => { + expect(watchResolutionToWireStatus("condition_met")).toBe("fired"); + expect(watchResolutionToWireStatus("window_completed")).toBe("expired"); + expect(watchResolutionToWireStatus("condition_impossible")).toBe("expired"); + }); +}); + +describe("watchRunDisposition", () => { + it("splits success from failure from cancellation", () => { + expect(watchRunDisposition("COMPLETED_SUCCESSFULLY")).toBe("succeeded"); + expect(watchRunDisposition("CANCELED")).toBe("cancelled"); + expect(watchRunDisposition(null)).toBe("unknown"); + for (const status of WATCH_FAILED_RUN_STATUSES) { + expect(watchRunDisposition(status)).toBe("failed"); + } + }); +}); + +describe("watchObservedOutcomeSchema", () => { + it("accepts one shape per kind", () => { + const outcomes = [ + { kind: "run_start", started: true, status: "EXECUTING" }, + { kind: "run_finished", finalStatus: "COMPLETED_SUCCESSFULLY", durationMs: 1200 }, + { kind: "run_failed", finalStatus: "COMPLETED_WITH_ERRORS", durationMs: 900 }, + { kind: "backlog_drain", depth: 0 }, + { kind: "queue_depth_above", depth: 612, threshold: 500 }, + { kind: "error_recurrence", countSince: 3 }, + { kind: "health_recovery", severity: "ok" }, + ]; + for (const outcome of outcomes) { + expect(watchObservedOutcomeSchema.safeParse(outcome).success).toBe(true); + } + expect(outcomes).toHaveLength(WATCH_KINDS.length); + }); + + it("defaults `verified` to true", () => { + const parsed = watchObservedOutcomeSchema.parse({ kind: "backlog_drain", depth: 0 }); + expect(parsed.verified).toBe(true); + }); +}); + +describe("resolveWatchResult", () => { + it("covers every kind × resolution cell", () => { + for (const kind of WATCH_KINDS) { + for (const resolution of watchResolutions) { + const result = resolveWatchResult({ kind, resolution }); + expect(watchHeadlineKeys).toContain(result.headlineKey); + expect(["positive", "attention", "neutral"]).toContain(result.category); + } + } + }); + + // §4.2: one resolution, two opposite presentations. This is the whole reason + // the resolution alone is insufficient. + it("splits run_finished on the observed final status", () => { + const ok = resolveWatchResult({ + kind: "run_finished", + resolution: "condition_met", + outcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_SUCCESSFULLY", + durationMs: null, + }, + }); + const failed = resolveWatchResult({ + kind: "run_finished", + resolution: "condition_met", + outcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: null, + }, + }); + + expect(ok).toMatchObject({ category: "positive", headlineKey: "run_finished" }); + expect(failed).toMatchObject({ category: "attention", headlineKey: "run_failed" }); + }); + + // Binding (§4.2): "a failed run must never wear a success check". + it("gives the failed run a non-success icon", () => { + const failed = resolveWatchResult({ + kind: "run_finished", + resolution: "condition_met", + outcome: { kind: "run_finished", verified: true, finalStatus: "CRASHED", durationMs: null }, + }); + expect(failed.semanticIcon).not.toBe("success"); + expect(failed.tone).toBe("error"); + }); + + it("presents a cancelled run neutrally, not as a success", () => { + expect( + resolveWatchResult({ + kind: "run_finished", + resolution: "condition_met", + outcome: { + kind: "run_finished", + verified: true, + finalStatus: "CANCELED", + durationMs: null, + }, + }) + ).toMatchObject({ category: "neutral", headlineKey: "run_cancelled" }); + }); + + it("does not infer tone from a good-news kind list", () => { + // Same resolution, opposite categories — proof the mapping is per kind. + expect( + resolveWatchResult({ kind: "backlog_drain", resolution: "window_completed" }).category + ).toBe("attention"); + expect( + resolveWatchResult({ kind: "error_recurrence", resolution: "window_completed" }).category + ).toBe("positive"); + expect( + resolveWatchResult({ kind: "queue_depth_above", resolution: "window_completed" }).category + ).toBe("positive"); + expect( + resolveWatchResult({ kind: "queue_depth_above", resolution: "condition_met" }).category + ).toBe("attention"); + }); + + it("says the condition could not be confirmed when the final read failed", () => { + for (const kind of WATCH_KINDS) { + const outcome = watchObservedOutcomeSchema.parse( + kind === "queue_depth_above" + ? { kind, verified: false, threshold: 500 } + : { kind, verified: false } + ); + expect( + resolveWatchResult({ kind: kind as WatchKind, resolution: "window_completed", outcome }) + ).toMatchObject({ category: "neutral", headlineKey: "unverified_at_window_end" }); + } + }); + + it("never claims a met condition was unverified", () => { + const outcome = watchObservedOutcomeSchema.parse({ kind: "backlog_drain", verified: false }); + expect( + resolveWatchResult({ kind: "backlog_drain", resolution: "condition_met", outcome }) + .headlineKey + ).toBe("queue_drained"); + }); +}); diff --git a/internal-packages/dashboard-agent-contracts/src/watch.ts b/internal-packages/dashboard-agent-contracts/src/watch.ts new file mode 100644 index 00000000000..50d77a65932 --- /dev/null +++ b/internal-packages/dashboard-agent-contracts/src/watch.ts @@ -0,0 +1,585 @@ +/** + * Watches — "tell me when X happens". The agent proposes a WatchSpec, the host + * persists it and polls the condition on the spec's cadence until it fires, + * expires, or is cancelled. + * + * Cadence limits are enforced in the SCHEMA, not in comments: run-state watches + * may poll every minute because a run flipping state is cheap to check, while + * aggregate conditions (backlog, error recurrence, health) are floored at 5 + * minutes so a watch can never turn into a hot loop over analytics data. + */ +import { z } from "zod"; + +/** Run-state conditions can be checked every minute. */ +export const runStateCadenceSchema = z.object({ + checkEveryMinutes: z.union([z.literal(1), z.literal(5), z.literal(15), z.literal(60)]), +}); + +/** Aggregate conditions are floored at 5 minutes. A 1-minute value fails validation. */ +export const standardCadenceSchema = z.object({ + checkEveryMinutes: z.union([z.literal(5), z.literal(15), z.literal(60)]), +}); + +export type RunStateCadence = z.infer; +export type StandardCadence = z.infer; + +/** Hard ceiling on how long a watch may live. */ +export const WATCH_MAX_HOURS = 24; + +export const watchCommonSchema = z.object({ + /** How long to keep checking before expiring, in hours. At most 24. */ + maxHours: z.number().positive().max(WATCH_MAX_HOURS), + /** Why this watch exists, in the user's terms. Shown when it fires. */ + note: z.string(), +}); + +export type WatchCommon = z.infer; + +/** Hard ceiling on the `queue_depth_above` threshold — a queue watch is not a query. */ +export const WATCH_MAX_QUEUE_THRESHOLD = 1_000_000; + +export const watchSpecSchema = z.union([ + watchCommonSchema + .extend({ kind: z.literal("run_start"), runId: z.string() }) + .merge(runStateCadenceSchema), + watchCommonSchema + .extend({ kind: z.literal("run_finished"), runId: z.string() }) + .merge(runStateCadenceSchema), + // The other half of the run pair (§3): "tell me IF it fails" rather than "tell + // me WHEN it lands". Same point read, opposite question — a successful + // completion makes a failure impossible, which is the good news here. + watchCommonSchema + .extend({ kind: z.literal("run_failed"), runId: z.string() }) + .merge(runStateCadenceSchema), + watchCommonSchema + .extend({ kind: z.literal("backlog_drain"), queue: z.string() }) + .merge(standardCadenceSchema), + // The inverse of `backlog_drain`, on the same depth reader: satisfied when the + // queue's pending count RISES ABOVE `threshold`. Aggregate, so the 5-minute floor + // applies — a threshold watch must never become a hot loop over the queue. + watchCommonSchema + .extend({ + kind: z.literal("queue_depth_above"), + queue: z.string(), + threshold: z.number().int().nonnegative().max(WATCH_MAX_QUEUE_THRESHOLD), + }) + .merge(standardCadenceSchema), + // `since` (the timestamp recurrence is measured from) is SERVER-SET when the + // watch is persisted, so it is deliberately absent here: the model must not be + // able to backdate a recurrence window. + watchCommonSchema + .extend({ kind: z.literal("error_recurrence"), fingerprint: z.string() }) + .merge(standardCadenceSchema), + watchCommonSchema + .extend({ + kind: z.literal("health_recovery"), + report: z.literal("health"), + fromSeverity: z.enum(["warn", "crit"]), + }) + .merge(standardCadenceSchema), +]); + +export type WatchSpec = z.infer; +export type WatchKind = WatchSpec["kind"]; + +export const WATCH_KINDS = [ + "run_start", + "run_finished", + "run_failed", + "backlog_drain", + "queue_depth_above", + "error_recurrence", + "health_recovery", +] as const satisfies readonly WatchKind[]; + +/** Whether a string names a watch kind THIS build knows how to present. */ +export function isWatchKind(kind: string): kind is WatchKind { + return (WATCH_KINDS as readonly string[]).includes(kind); +} + +/** + * The condition a watch is watching, as a stable string, scoped to one + * environment. Two watches with the same identity in the same environment watch + * the same thing and should be deduplicated — cadence, note, and maxHours are + * NOT part of the identity, so re-asking with a different cadence updates the + * existing watch rather than creating a second one. + */ +export function watchIdentity(spec: WatchSpec): string { + switch (spec.kind) { + case "run_start": + case "run_finished": + case "run_failed": + return `${spec.kind}:${spec.runId}`; + case "backlog_drain": + return `backlog_drain:${spec.queue}`; + // The threshold IS part of the identity: "above 500" and "above 5000" are two + // different questions about the same queue, unlike cadence or note. + case "queue_depth_above": + return `queue_depth_above:${spec.queue}:${spec.threshold}`; + case "error_recurrence": + return `error_recurrence:${spec.fingerprint}`; + case "health_recovery": + return `health_recovery:${spec.report}`; + default: { + const unreachable: never = spec; + throw new Error(`Unhandled watch kind: ${JSON.stringify(unreachable)}`); + } + } +} + +/** + * The outcome of one poll: + * - `pending` — not yet, keep checking. + * - `satisfied` — the condition happened; fire and notify. + * - `terminal_unsatisfied` — it can never happen now (e.g. waiting for a run to + * start that got cancelled). Stop checking; this is not a failure. + * - `unavailable` — the check itself couldn't run (data source down, permission + * lost). Keep the watch alive and retry. + */ +export const watchCheckResults = [ + "pending", + "satisfied", + "terminal_unsatisfied", + "unavailable", +] as const; + +export const watchCheckResultSchema = z.enum(watchCheckResults); +export type WatchCheckResult = z.infer; + +/** + * How a watch ENDED. Three values, not two: a watch does not "fire or expire", it + * resolves and reports once. + * + * - `condition_met` — the checked condition became true inside the window. + * - `window_completed` — the window ran out with the condition still not true. + * An answer, not silence: "it didn't drain in an hour" is the thing the user + * asked to be told. + * - `condition_impossible` — it can no longer become true (terminal state, the + * object is gone). + * + * The resolution alone does not decide what the user sees — see + * {@link resolveWatchResult}. `unavailable` is deliberately NOT here: a check that + * couldn't run never resolves anything. + */ +export const watchResolutions = [ + "condition_met", + "window_completed", + "condition_impossible", +] as const; +export const watchResolutionSchema = z.enum(watchResolutions); +export type WatchResolution = z.infer; + +/** + * The WIRE encoding of a resolution (spec §7.5, binding). + * + * The resolution model does not rename the on-the-wire identifiers: wake action + * ids (`wake:watch:{id}:{fired|expired}`), delivery ids (`watch:{id}:{status}`) + * and banner render keys keep their as-built two-value suffix, so persisted wakes + * and dedup keys stay valid. The resolution itself travels in the action's FACTS, + * never in the id. + */ +export function watchResolutionToWireStatus(resolution: WatchResolution): "fired" | "expired" { + return resolution === "condition_met" ? "fired" : "expired"; +} + +/** + * The lifecycle rule that turns a tick into a resolution — the window boundary, + * §7.4 (binding). + * + * A check that lands ON the deadline may still resolve `condition_met` or + * `condition_impossible`: the final evaluation is a real evaluation, not a + * formality. Only a `pending` or `unavailable` final result becomes + * `window_completed`. Before the deadline, `pending` and `unavailable` resolve + * nothing at all — the watch stays alive and retries. + */ +export function watchResolutionForCheck( + result: WatchCheckResult, + atWindowBoundary: boolean +): WatchResolution | null { + switch (result) { + case "satisfied": + return "condition_met"; + case "terminal_unsatisfied": + return "condition_impossible"; + case "pending": + case "unavailable": + return atWindowBoundary ? "window_completed" : null; + default: { + const unreachable: never = result; + throw new Error(`Unhandled watch check result: ${JSON.stringify(unreachable)}`); + } + } +} + +export const watchStatuses = ["active", "fired", "expired", "cancelled"] as const; +export const watchStatusSchema = z.enum(watchStatuses); +export type WatchStatus = z.infer; + +/** Whether the user still needs to be told this watch fired. */ +export const watchDeliveryStatuses = ["not_required", "pending", "delivered"] as const; +export const watchDeliveryStatusSchema = z.enum(watchDeliveryStatuses); +export type WatchDeliveryStatus = z.infer; + +/* ------------------------------------------------------------------ * + * Observed outcome — WHAT the resolving check saw + * ------------------------------------------------------------------ */ + +/** + * The run statuses that count as a FAILURE for presentation. `run_finished` + * resolves `condition_met` on any terminal status, so this set — not the + * resolution — is what separates "Run abc123 finished" from "Run abc123 failed". + */ +export const WATCH_FAILED_RUN_STATUSES = [ + "COMPLETED_WITH_ERRORS", + "SYSTEM_FAILURE", + "CRASHED", + "EXPIRED", + "TIMED_OUT", + "INTERRUPTED", +] as const; + +/** Ended on purpose. Neither a success nor a failure — its own presentation. */ +export const WATCH_CANCELLED_RUN_STATUSES = ["CANCELED"] as const; + +export type WatchRunDisposition = "succeeded" | "failed" | "cancelled" | "unknown"; + +/** Classify a run's final status for presentation. */ +export function watchRunDisposition(status: string | null | undefined): WatchRunDisposition { + if (!status) return "unknown"; + if (status === "COMPLETED_SUCCESSFULLY") return "succeeded"; + if ((WATCH_FAILED_RUN_STATUSES as readonly string[]).includes(status)) return "failed"; + if ((WATCH_CANCELLED_RUN_STATUSES as readonly string[]).includes(status)) return "cancelled"; + return "unknown"; +} + +/** + * What the resolving check OBSERVED, per kind — the second half of a resolved + * result. Stored on the row next to the resolution and frozen with the facts, so + * every delivery surface reads one set of observations and none of them re-reads + * the source to reconstruct what happened (§7.5). + * + * `verified` is common to all of them: false when the window completed while the + * source was unavailable, so the presentation says the condition couldn't be + * confirmed rather than claiming it didn't happen (§4.2). + */ +export const watchObservedOutcomeSchema = z.union([ + z.object({ + kind: z.literal("run_start"), + verified: z.boolean().default(true), + /** The run's status at the resolving check. */ + status: z.string().nullable().default(null), + started: z.boolean().default(false), + }), + z.object({ + kind: z.literal("run_finished"), + verified: z.boolean().default(true), + /** The run's FINAL status — the observation the presentation splits on. */ + finalStatus: z.string().nullable().default(null), + durationMs: z.number().nullable().default(null), + }), + z.object({ + kind: z.literal("run_failed"), + verified: z.boolean().default(true), + /** The run's FINAL status. Null while it is still running. */ + finalStatus: z.string().nullable().default(null), + durationMs: z.number().nullable().default(null), + }), + z.object({ + kind: z.literal("backlog_drain"), + verified: z.boolean().default(true), + /** The depth the resolving check read. Null when it could not be read. */ + depth: z.number().nullable().default(null), + }), + z.object({ + kind: z.literal("queue_depth_above"), + verified: z.boolean().default(true), + depth: z.number().nullable().default(null), + threshold: z.number(), + }), + z.object({ + kind: z.literal("error_recurrence"), + verified: z.boolean().default(true), + /** Occurrences proven to be after the server-set `since`. */ + countSince: z.number().default(0), + }), + z.object({ + kind: z.literal("health_recovery"), + verified: z.boolean().default(true), + severity: z.enum(["ok", "warn", "crit"]).nullable().default(null), + }), +]); + +export type WatchObservedOutcome = z.infer; + +/* ------------------------------------------------------------------ * + * The resolved-result mapping — (kind + resolution + observed outcome) + * ------------------------------------------------------------------ */ + +/** + * The presentation classification. Declared per kind, never inferred from a + * "good news kind" list: `window_completed` is bad news for a drain watch and + * good news for an "error stayed quiet" one. + */ +export const watchPresentationCategories = ["positive", "attention", "neutral"] as const; +export const watchPresentationCategorySchema = z.enum(watchPresentationCategories); +export type WatchPresentationCategory = z.infer; + +/** The visual accent. Same four tokens the agent surfaces already speak. */ +export const watchPresentationTones = ["success", "warning", "error", "neutral"] as const; +export const watchPresentationToneSchema = z.enum(watchPresentationTones); +export type WatchPresentationTone = z.infer; + +/** + * The icon, named by MEANING rather than glyph, so a surface with a different + * icon set still shows the same thing. It follows the presentation outcome, never + * the bare resolution: a failed run never wears a success check (§4.2, binding). + */ +export const watchSemanticIcons = ["success", "attention", "error", "waiting", "info"] as const; +export const watchSemanticIconSchema = z.enum(watchSemanticIcons); +export type WatchSemanticIcon = z.infer; + +/** + * WHICH sentence to say. The final English wording is the host's job (the + * webapp's `watch-presentation.ts`); contracts only fixes the set of things a + * resolved watch can mean, so a second surface can't invent a seventh meaning. + */ +export const watchHeadlineKeys = [ + // run_start + "run_started", + "run_not_started", + "run_never_starts", + // run_finished + "run_finished", + "run_failed", + "run_cancelled", + "run_still_running", + "run_gone", + // run_failed + "run_no_failure", + "run_succeeded", + // backlog_drain + "queue_drained", + "queue_not_drained", + "queue_gone", + // queue_depth_above + "queue_above_threshold", + "queue_stayed_below", + // error_recurrence + "error_recurred", + "error_quiet", + // health_recovery + "health_recovered", + "health_not_recovered", + "health_unavailable", + // Any kind, when the window completed without a usable final read. + "unverified_at_window_end", +] as const; +export const watchHeadlineKeySchema = z.enum(watchHeadlineKeys); +export type WatchHeadlineKey = z.infer; + +/** One resolved result, as every surface consumes it. */ +export type WatchResolvedPresentation = { + category: WatchPresentationCategory; + tone: WatchPresentationTone; + semanticIcon: WatchSemanticIcon; + headlineKey: WatchHeadlineKey; +}; + +const POSITIVE: Omit = { + category: "positive", + tone: "success", + semanticIcon: "success", +}; +const ATTENTION_WARN: Omit = { + category: "attention", + tone: "warning", + semanticIcon: "attention", +}; +const ATTENTION_ERROR: Omit = { + category: "attention", + tone: "error", + semanticIcon: "error", +}; +const NEUTRAL: Omit = { + category: "neutral", + tone: "neutral", + semanticIcon: "info", +}; +const WAITING: Omit = { + category: "attention", + tone: "warning", + semanticIcon: "waiting", +}; + +/** + * The exhaustive per-kind mapping, as a TABLE: `kind × resolution` is a total + * `Record`, so adding a watch kind or a resolution value fails to compile until + * every cell is filled in. That is the point — the mapping may never fall through + * to a default that quietly presents a failure as a success. + * + * Cells the observed outcome refines (run_finished's final status) carry the + * DEFAULT here and are overridden in {@link resolveWatchResult}. + */ +const RESOLVED_RESULTS: Record> = { + run_start: { + condition_met: { ...POSITIVE, headlineKey: "run_started" }, + window_completed: { ...WAITING, headlineKey: "run_not_started" }, + condition_impossible: { ...NEUTRAL, headlineKey: "run_never_starts" }, + }, + run_finished: { + // Refined by the observed final status below — a completion WITH FAILURE + // presents as attention, however cleanly it resolved. + condition_met: { ...POSITIVE, headlineKey: "run_finished" }, + window_completed: { ...WAITING, headlineKey: "run_still_running" }, + condition_impossible: { ...NEUTRAL, headlineKey: "run_gone" }, + }, + // The inverse question about the same run: the failure is the bad news, and a + // window that ran out without one is the good news. `condition_impossible` + // means it can no longer fail — refined below into the plain success headline + // when a final status proves it. + run_failed: { + condition_met: { ...ATTENTION_ERROR, headlineKey: "run_failed" }, + window_completed: { ...POSITIVE, headlineKey: "run_no_failure" }, + condition_impossible: { ...NEUTRAL, headlineKey: "run_gone" }, + }, + backlog_drain: { + condition_met: { ...POSITIVE, headlineKey: "queue_drained" }, + window_completed: { ...ATTENTION_WARN, headlineKey: "queue_not_drained" }, + condition_impossible: { ...NEUTRAL, headlineKey: "queue_gone" }, + }, + // The inverted comparison inverts the presentation too: crossing the threshold + // is the bad news here, and the quiet window is the good one. + queue_depth_above: { + condition_met: { ...ATTENTION_WARN, headlineKey: "queue_above_threshold" }, + window_completed: { ...POSITIVE, headlineKey: "queue_stayed_below" }, + condition_impossible: { ...NEUTRAL, headlineKey: "queue_gone" }, + }, + error_recurrence: { + condition_met: { ...ATTENTION_ERROR, headlineKey: "error_recurred" }, + window_completed: { ...POSITIVE, headlineKey: "error_quiet" }, + // The fingerprint is gone from the environment: it cannot come back under + // this identity, which is the same good news as staying quiet. + condition_impossible: { ...NEUTRAL, headlineKey: "error_quiet" }, + }, + health_recovery: { + condition_met: { ...POSITIVE, headlineKey: "health_recovered" }, + window_completed: { ...ATTENTION_WARN, headlineKey: "health_not_recovered" }, + condition_impossible: { ...NEUTRAL, headlineKey: "health_unavailable" }, + }, +}; + +/** + * The one place a resolved watch becomes something to show — resolution PLUS + * observed outcome, never the resolution alone. + * + * Shape of the argument is (kind, resolution, observed outcome) on purpose: the + * kind comes from the spec, the resolution from the lifecycle, the outcome from + * the resolving check — and no surface may substitute its own third input. + */ +export function resolveWatchResult(args: { + kind: WatchKind; + resolution: WatchResolution; + outcome?: WatchObservedOutcome | null; +}): WatchResolvedPresentation { + const { kind, resolution, outcome } = args; + + // A window that completed without a usable final read is its own answer: the + // condition could not be CONFIRMED, which is not "it didn't happen". + if (resolution === "window_completed" && outcome && outcome.verified === false) { + return { ...NEUTRAL, headlineKey: "unverified_at_window_end" }; + } + + // The one cell the observed outcome decides: a run that finished is not + // automatically good news. `unknown` keeps the plain "finished" headline — + // claiming a failure nobody observed is the worse mistake. + if (kind === "run_finished" && resolution === "condition_met") { + const disposition = watchRunDisposition( + outcome?.kind === "run_finished" ? outcome.finalStatus : null + ); + if (disposition === "failed") return { ...ATTENTION_ERROR, headlineKey: "run_failed" }; + if (disposition === "cancelled") return { ...NEUTRAL, headlineKey: "run_cancelled" }; + } + + // The mirror cell: a failure watch that can never be satisfied because the run + // SUCCEEDED is good news, not the neutral "it's gone" the default assumes. + if (kind === "run_failed" && resolution === "condition_impossible") { + const disposition = watchRunDisposition( + outcome?.kind === "run_failed" ? outcome.finalStatus : null + ); + if (disposition === "succeeded") return { ...POSITIVE, headlineKey: "run_succeeded" }; + if (disposition === "cancelled") return { ...NEUTRAL, headlineKey: "run_cancelled" }; + } + + return RESOLVED_RESULTS[kind][resolution]; +} + +/* ------------------------------------------------------------------ * + * The configuration card — what the dashboard's `Watch…` entry offers + * ------------------------------------------------------------------ */ + +/** + * The follow-up section of the card (§2.2, binding). + * + * In-chat delivery is FIXED and always on, so it is deliberately absent here: + * there is nothing to toggle. What remains is two **independent** opt-ins — never + * a radio group, because a user must not be able to choose email *instead of* the + * chat. + */ +export const watchFollowUpSchema = z.object({ + /** Open an investigation when the outcome is an attention one (§6). */ + investigateOnAttention: z.boolean().default(false), + /** Attach an external delivery subscription (email) to this watch (§6). */ + notifyExternally: z.boolean().default(false), +}); + +export type WatchFollowUp = z.infer; + +/** What the card submits: the configured spec plus its follow-up opt-ins. */ +export const watchDraftSchema = z.object({ + spec: watchSpecSchema, + followUp: watchFollowUpSchema, +}); + +export type WatchDraft = z.infer; + +/** The window lengths the card offers, in hours. Capped by {@link WATCH_MAX_HOURS}. */ +export const WATCH_WINDOW_HOURS_OPTIONS = [0.5, 1, 2, 6, 12, 24] as const; + +/** Run-state kinds may poll every minute; aggregates are floored at 5 (§7.1). */ +const RUN_STATE_KINDS = ["run_start", "run_finished", "run_failed"] as const; + +/** Whether a kind reads one run row (cheap) rather than an aggregate. */ +export function isRunStateWatchKind(kind: WatchKind): boolean { + return (RUN_STATE_KINDS as readonly string[]).includes(kind); +} + +/** + * The cadences the card may offer for a kind — the SCHEMA's limits, surfaced so + * the picker can't render an option that would then fail validation. + */ +export function watchCadenceOptions(kind: WatchKind): readonly number[] { + return isRunStateWatchKind(kind) ? [1, 5, 15, 60] : [5, 15, 60]; +} + +/** + * The condition variant sitting next to a kind under **Customize** (§3): "run + * finishes ↔ run fails", "queue drains ↔ queue above N". Null for the kinds that + * have no second question in this iteration. + */ +export function watchVariantKind(kind: WatchKind): WatchKind | null { + switch (kind) { + case "run_finished": + return "run_failed"; + case "run_failed": + return "run_finished"; + case "backlog_drain": + return "queue_depth_above"; + case "queue_depth_above": + return "backlog_drain"; + default: + return null; + } +} + +/** The default threshold a `queue_depth_above` variant starts on. */ +export const WATCH_DEFAULT_QUEUE_THRESHOLD = 100; diff --git a/internal-packages/dashboard-agent-db/README.md b/internal-packages/dashboard-agent-db/README.md index 147bcbe2555..3c1a41709c4 100644 --- a/internal-packages/dashboard-agent-db/README.md +++ b/internal-packages/dashboard-agent-db/README.md @@ -30,7 +30,7 @@ of truth. - `chats` — one row per conversation: org/user scope, title, a `messages` JSONB display copy of the transcript, and `metadata` (the project/env context the chat ran in). Soft-deleted via `deleted_at`, pinned via `pinned_at`, read-marked via - `last_read_at` (NULL = never read). + `last_read_at` (NULL = never read, so every watch wake in it counts as unread). - `chat_sessions` — live transport state keyed by `chat_id`: the session-scoped `public_access_token` and `last_event_id` for resume. Separate table so the secret token is isolated from list queries and the hot per-turn write stays off @@ -44,6 +44,16 @@ of truth. `revision` is bumped by a single atomic `revision = revision + 1` update, and the `chat_id`/`project_ref`/`environment_ref` triple must match on every commit. `state` is intentionally untyped JSONB — the payload shape isn't frozen yet. +- `watches` — "tell me when X happens", checked by a periodic task. `status` + (`active | fired | expired | cancelled`) and `delivery_status` + (`not_required | pending | delivered`) are guarded in the query layer with + `WHERE status = 'active' … RETURNING`, so concurrent fire/expire/cancel resolves + to one winner. The org/project/env/user identity is a snapshot taken at creation + and never updated — a watch fires with exactly the access its creator had. + `identity` is the dedup key for the watched thing: a partial unique index on + `(chat_id, project_id, environment_id, identity) WHERE status = 'active'` is what + actually prevents duplicates, since a read-then-insert check can't be race-proof. + A chat may hold at most three active watches (best-effort, not a hard cap). ## Migrations diff --git a/internal-packages/dashboard-agent-db/drizzle/0002_luxuriant_king_cobra.sql b/internal-packages/dashboard-agent-db/drizzle/0002_luxuriant_king_cobra.sql index 072774d2444..042b4298958 100644 --- a/internal-packages/dashboard-agent-db/drizzle/0002_luxuriant_king_cobra.sql +++ b/internal-packages/dashboard-agent-db/drizzle/0002_luxuriant_king_cobra.sql @@ -9,4 +9,29 @@ CREATE TABLE "trigger_dashboard_agent"."investigations" ( "updated_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE INDEX "investigations_chat_idx" ON "trigger_dashboard_agent"."investigations" USING btree ("chat_id"); +CREATE TABLE "trigger_dashboard_agent"."watches" ( + "id" text PRIMARY KEY NOT NULL, + "chat_id" text NOT NULL, + "identity" text NOT NULL, + "spec" jsonb NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "delivery_status" text DEFAULT 'not_required' NOT NULL, + "cancel_reason" text, + "organization_id" text NOT NULL, + "project_id" text NOT NULL, + "environment_id" text NOT NULL, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "last_checked_at" timestamp with time zone, + "fired_at" timestamp with time zone, + "delivered_at" timestamp with time zone, + "cancelled_at" timestamp with time zone, + "last_result" jsonb, + "tick_count" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE INDEX "investigations_chat_idx" ON "trigger_dashboard_agent"."investigations" USING btree ("chat_id");--> statement-breakpoint +CREATE INDEX "watches_chat_idx" ON "trigger_dashboard_agent"."watches" USING btree ("chat_id");--> statement-breakpoint +CREATE UNIQUE INDEX "watches_chat_active_identity_key" ON "trigger_dashboard_agent"."watches" USING btree ("chat_id","project_id","environment_id","identity") WHERE "trigger_dashboard_agent"."watches"."status" = 'active';--> statement-breakpoint +CREATE INDEX "watches_status_expires_idx" ON "trigger_dashboard_agent"."watches" USING btree ("status","expires_at"); \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/0004_moaning_omega_sentinel.sql b/internal-packages/dashboard-agent-db/drizzle/0004_moaning_omega_sentinel.sql new file mode 100644 index 00000000000..e9f58cce809 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0004_moaning_omega_sentinel.sql @@ -0,0 +1 @@ +CREATE INDEX "watches_pending_delivery_idx" ON "trigger_dashboard_agent"."watches" USING btree ("fired_at","last_checked_at") WHERE "trigger_dashboard_agent"."watches"."delivery_status" = 'pending'; \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/0005_aspiring_unus.sql b/internal-packages/dashboard-agent-db/drizzle/0005_aspiring_unus.sql new file mode 100644 index 00000000000..f208999a86e --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0005_aspiring_unus.sql @@ -0,0 +1,3 @@ +DROP INDEX "trigger_dashboard_agent"."watches_pending_delivery_idx";--> statement-breakpoint +ALTER TABLE "trigger_dashboard_agent"."watches" ADD COLUMN "delivery_claimed_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "watches_pending_delivery_idx" ON "trigger_dashboard_agent"."watches" USING btree ("fired_at","last_checked_at") WHERE "trigger_dashboard_agent"."watches"."delivery_status" in ('pending', 'delivering'); \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/0006_wooden_hex.sql b/internal-packages/dashboard-agent-db/drizzle/0006_wooden_hex.sql new file mode 100644 index 00000000000..1aee1fbc884 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0006_wooden_hex.sql @@ -0,0 +1 @@ +ALTER TABLE "trigger_dashboard_agent"."watches" ADD COLUMN "delivery_claim_id" text; \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/0007_glamorous_colleen_wing.sql b/internal-packages/dashboard-agent-db/drizzle/0007_glamorous_colleen_wing.sql new file mode 100644 index 00000000000..845d156e723 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0007_glamorous_colleen_wing.sql @@ -0,0 +1,2 @@ +ALTER TABLE "trigger_dashboard_agent"."watches" ADD COLUMN "resolution" text;--> statement-breakpoint +ALTER TABLE "trigger_dashboard_agent"."watches" ADD COLUMN "observed_outcome" jsonb; \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/0008_chunky_spot.sql b/internal-packages/dashboard-agent-db/drizzle/0008_chunky_spot.sql new file mode 100644 index 00000000000..5e81fa0523e --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0008_chunky_spot.sql @@ -0,0 +1,2 @@ +ALTER TABLE "trigger_dashboard_agent"."watches" ADD COLUMN "investigate_on_attention" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "trigger_dashboard_agent"."watches" ADD COLUMN "project_ref" text; \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0002_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0002_snapshot.json index d4cf7c16499..d4f28573ff0 100644 --- a/internal-packages/dashboard-agent-db/drizzle/meta/0002_snapshot.json +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0002_snapshot.json @@ -503,6 +503,208 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": {}, diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0003_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0003_snapshot.json index 0179ab7780a..41740446d65 100644 --- a/internal-packages/dashboard-agent-db/drizzle/meta/0003_snapshot.json +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0003_snapshot.json @@ -509,6 +509,208 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": {}, diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000000..c00c032097e --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,751 @@ +{ + "id": "762767b5-e7e9-4561-a9cb-d51a95149bee", + "prevId": "b79cb5ef-25c0-40ab-877e-9e9ed462ba97", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000000..02dcf86bec5 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,757 @@ +{ + "id": "9c249eee-ff92-457e-b567-aa883095d741", + "prevId": "762767b5-e7e9-4561-a9cb-d51a95149bee", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0006_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000000..c0420364464 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,763 @@ +{ + "id": "9b9a3bfd-59ae-4d36-a6f5-35badd82774f", + "prevId": "9c249eee-ff92-457e-b567-aa883095d741", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0007_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000000..a1ed7182997 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0007_snapshot.json @@ -0,0 +1,775 @@ +{ + "id": "21d8439f-e7c1-4354-b0d0-5ffce3cfc58a", + "prevId": "9b9a3bfd-59ae-4d36-a6f5-35badd82774f", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_outcome": { + "name": "observed_outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0008_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000000..ae522c3e362 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,788 @@ +{ + "id": "19a98528-b006-4694-b342-5a83f82ae36c", + "prevId": "21d8439f-e7c1-4354-b0d0-5ffce3cfc58a", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_outcome": { + "name": "observed_outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "investigate_on_attention": { + "name": "investigate_on_attention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json index 04376fb96bc..f94a5ae1672 100644 --- a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json +++ b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json @@ -29,6 +29,41 @@ "when": 1785320232281, "tag": "0003_famous_champions", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785511915186, + "tag": "0004_moaning_omega_sentinel", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1785518300333, + "tag": "0005_aspiring_unus", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1785527453164, + "tag": "0006_wooden_hex", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1785706535148, + "tag": "0007_glamorous_colleen_wing", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1785708513120, + "tag": "0008_chunky_spot", + "breakpoints": true } ] } diff --git a/internal-packages/dashboard-agent-db/src/ids.ts b/internal-packages/dashboard-agent-db/src/ids.ts index 1e8db4b05b3..e88e69abca6 100644 --- a/internal-packages/dashboard-agent-db/src/ids.ts +++ b/internal-packages/dashboard-agent-db/src/ids.ts @@ -1,7 +1,7 @@ import { randomInt } from "node:crypto"; /** - * Friendly ids for rows this package creates itself (`inv_…`). + * Friendly ids for rows this package creates itself (`inv_…`, `watch_…`). * * Same shape as the platform's `generateFriendlyId` — `prefix_` + 21 chars of the * lowercase alphanumeric alphabet with look-alikes (`0`, `l`) removed — but @@ -19,3 +19,6 @@ export function generateId(prefix: string, size: number = SIZE): string { } export const generateInvestigationId = () => generateId("inv"); +export const generateWatchId = () => generateId("watch"); +/** Fencing token for one wake-delivery claim (`wdc_…`). */ +export const generateWatchDeliveryClaimId = () => generateId("wdc"); diff --git a/internal-packages/dashboard-agent-db/src/queries.ts b/internal-packages/dashboard-agent-db/src/queries.ts index 8107b1648d0..fa5206ba424 100644 --- a/internal-packages/dashboard-agent-db/src/queries.ts +++ b/internal-packages/dashboard-agent-db/src/queries.ts @@ -1,14 +1,24 @@ -import { and, desc, eq, ne, sql, isNull } from "drizzle-orm"; +import { and, desc, eq, inArray, ne, sql, isNull } from "drizzle-orm"; +import { + watchResolutionToWireStatus, + type WatchObservedOutcome, + type WatchResolution, +} from "@internal/dashboard-agent-contracts"; import type { DashboardAgentDb } from "./client.js"; -import { generateInvestigationId } from "./ids.js"; +import { generateInvestigationId, generateWatchDeliveryClaimId, generateWatchId } from "./ids.js"; import { chats, chatSessions, chatTurnEvals, investigations, + watches, type ChatSession, type Investigation, type NewChatTurnEval, + type PersistedWatchSpec, + type Watch, + type WatchCancelReason, + type WatchStatus, } from "./schema.js"; /** @@ -20,6 +30,14 @@ import { /** Placeholder title for a chat with no generated or user-set title yet. */ export const DEFAULT_CHAT_TITLE = "New chat"; +/** + * The db handle or an already-open transaction, for the queries that are also + * called as one step of a larger atomic write. + */ +type DashboardAgentDbOrTx = + | DashboardAgentDb + | Parameters[0]>[0]; + export interface ChatListItem { id: string; title: string; @@ -246,20 +264,55 @@ export async function markChatRead( } /** - * #5 Soft-delete a chat. Owner-scoped, so a chatId the caller doesn't own deletes - * nothing. Returns whether a row was actually deleted. + * Advisory-lock namespace for the per-chat watch lock — ASCII `watc`, so the + * (namespace, hashtext(chatId)) pair can't collide with another lock's key space. + */ +const WATCH_CHAT_LOCK_NAMESPACE = 0x77617463; + +/** + * Serialize everything that decides "may this chat have this watch?" — creating a + * watch and deleting the chat under it. Transaction-scoped, so it is held to + * commit and released by Postgres whatever happens. + */ +function lockChatForWatches(tx: DashboardAgentDbOrTx, chatId: string) { + return tx.execute( + sql`select pg_advisory_xact_lock(${WATCH_CHAT_LOCK_NAMESPACE}, hashtext(${chatId}))` + ); +} + +/** + * #5 Soft-delete a chat AND end its watches, in one transaction. + * + * The two halves must not be separable: a deleted chat has nowhere to deliver a + * watch outcome, so a crash between them would leave live watches ticking against + * a conversation the user can no longer see. Owner-scoped, so a chatId the caller + * doesn't own deletes nothing and cancels nothing. */ export async function softDeleteChat( db: DashboardAgentDb, params: { chatId: string; userId: string } -): Promise<{ deleted: boolean }> { - const deleted = await db - .update(chats) - .set({ deletedAt: sql`now()`, updatedAt: sql`now()` }) - .where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId))) - .returning({ id: chats.id }); +): Promise<{ deleted: boolean; cancelledWatches: Watch[] }> { + return db.transaction(async (tx) => { + // The SAME lock `createWatch` takes. Without it a create that resolved a live + // chat can commit its insert after this transaction cancelled the chat's + // watches, leaving an active watch on a deleted chat. + await lockChatForWatches(tx, params.chatId); + + const deleted = await tx + .update(chats) + .set({ deletedAt: sql`now()`, updatedAt: sql`now()` }) + .where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId))) + .returning({ id: chats.id }); + + if (deleted.length === 0) return { deleted: false, cancelledWatches: [] }; - return { deleted: deleted.length > 0 }; + const cancelledWatches = await cancelActiveWatchesForChat(tx, { + chatId: params.chatId, + reason: "chat_deleted", + }); + + return { deleted: true, cancelledWatches }; + }); } /** @@ -276,6 +329,38 @@ export async function persistMessages( .where(eq(chats.id, params.chatId)); } +/** + * Append ONE message to a chat's transcript, atomically. + * + * This is the deterministic-append seam: the watch card's confirmation and its + * one-shot result block are host-decided facts, not model output, so they are + * written straight onto the transcript with no turn and no LLM. `||` on the JSONB + * column is a single statement, so it cannot lose a concurrent turn's write the + * way a read-modify-write from the app would. + * + * Owner-scoped and live-chat-scoped: a chatId the caller doesn't own appends + * nothing and returns false, so ownership never has to be re-proved by the caller + * after the fact. + */ +export async function appendChatMessage( + db: DashboardAgentDb, + params: { chatId: string; userId: string; message: unknown } +): Promise { + const rows = await db + .update(chats) + .set({ + messages: sql`coalesce(${chats.messages}, '[]'::jsonb) || ${JSON.stringify([params.message])}::jsonb`, + lastMessageAt: sql`now()`, + updatedAt: sql`now()`, + }) + .where( + and(eq(chats.id, params.chatId), eq(chats.userId, params.userId), isNull(chats.deletedAt)) + ) + .returning({ id: chats.id }); + + return rows.length > 0; +} + /** * #6b Persist a completed turn (agent `onTurnComplete`): the finalized transcript * and the refreshed session state, in one transaction. Atomicity matters — on @@ -440,8 +525,8 @@ export async function listInvestigationsForChat( * the outcome is checked on it — a chat whose old investigation stopped at * `in_progress` but has a newer concluded one is NOT investigating. * - * Tenancy floor is the join: org + user + not-deleted on the chat, so nothing - * outside this user's chats can match. + * Tenancy floor is the join, same as {@link listActiveWatchesForChats}: org + + * user + not-deleted on the chat, so nothing outside this user's chats can match. */ export async function listChatIdsWithOpenInvestigations( db: DashboardAgentDb, @@ -471,3 +556,865 @@ export async function listChatIdsWithOpenInvestigations( return new Set(rows.map((row) => row.chatId)); } + +/* ------------------------------------------------------------------ * + * Watches + * ------------------------------------------------------------------ */ + +/** Guardrail: a chat may hold at most this many watches at once. */ +export const MAX_ACTIVE_WATCHES_PER_CHAT = 3; + +/** Terminal statuses are immutable — every transition guards on `active`. */ +export function isTerminalWatchStatus(status: string): boolean { + return status === "fired" || status === "expired" || status === "cancelled"; +} + +/** + * A wake that still has to reach its chat: never claimed, or claimed by a + * deliverer that hasn't marked it delivered (yet, or ever). Whether the claim is + * still someone's to hold is {@link claimWatchDelivery}'s call, not this one's. + */ +export function isWatchDeliveryOwed(status: string): boolean { + return status === "pending" || status === "delivering"; +} + +export type CreateWatchResult = + | { ok: true; watch: Watch } + | { ok: false; error: "limit_reached"; activeCount: number } + | { ok: false; error: "duplicate"; existingId: string | null } + /** The chat is gone (or was deleted while this create was in flight). */ + | { ok: false; error: "chat_not_found" }; + +/** Postgres `unique_violation`. */ +const PG_UNIQUE_VIOLATION = "23505"; + +function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as { code?: unknown }).code === PG_UNIQUE_VIOLATION + ); +} + +/** + * #13 Create a watch. The caller supplies the already-resolved identity — both + * the tenancy snapshot (org/project/env/user, frozen for the watch's life) and + * the `identity` dedup string for the thing being watched. + * + * Two guardrails: + * + * - **Dedup** (no second active watch on the same chat/project/environment/identity) + * is guaranteed by the partial unique index `watches_chat_active_identity_key`. + * The pre-check below exists only to return a friendly result with the existing + * watch's id in the common case; a concurrent double-submit slips past it (both + * transactions can read no duplicate under READ COMMITTED) and is caught as a + * unique violation on insert. + * - **The ≤`MAX_ACTIVE_WATCHES_PER_CHAT` limit** is a hard cap: the count and the + * insert are one transaction, serialized per chat by a transaction-scoped + * advisory lock, so concurrent creates queue behind each other and the one that + * would be the 4th is rejected instead of landing. + * + * The same lock also serializes against `softDeleteChat`, and the chat is re-read + * inside it, so a create can never land an active watch on a chat that was deleted + * while the create was in flight. + */ +export async function createWatch( + db: DashboardAgentDb, + params: { + chatId: string; + identity: string; + spec: PersistedWatchSpec; + organizationId: string; + projectId: string; + /** The project's external `proj_…` ref — what a wake scopes an investigation by. */ + projectRef?: string | null; + environmentId: string; + userId: string; + expiresAt: Date; + /** Consent, given at creation, to investigate after an attention outcome. */ + investigateOnAttention?: boolean; + id?: string; + } +): Promise { + try { + return await db.transaction(async (tx) => { + // Serialize this chat's watch decisions, so count-then-insert is atomic + // against a concurrent create AND against the chat being deleted underneath. + await lockChatForWatches(tx, params.chatId); + + // Re-read the chat under the lock. A delete that committed while this call + // was validating its target would otherwise be overtaken by the insert + // below, leaving an active watch on a conversation the user has deleted. + const chat = await tx + .select({ id: chats.id }) + .from(chats) + .where(and(eq(chats.id, params.chatId), isNull(chats.deletedAt))) + .limit(1); + if (chat.length === 0) return { ok: false, error: "chat_not_found" } as const; + + const active = await tx + .select({ + id: watches.id, + identity: watches.identity, + projectId: watches.projectId, + environmentId: watches.environmentId, + }) + .from(watches) + .where(and(eq(watches.chatId, params.chatId), eq(watches.status, "active"))); + + const duplicate = active.find( + (w) => + w.identity === params.identity && + w.projectId === params.projectId && + w.environmentId === params.environmentId + ); + if (duplicate) { + return { ok: false, error: "duplicate", existingId: duplicate.id }; + } + + if (active.length >= MAX_ACTIVE_WATCHES_PER_CHAT) { + return { ok: false, error: "limit_reached", activeCount: active.length }; + } + + const rows = await tx + .insert(watches) + .values({ + id: params.id ?? generateWatchId(), + chatId: params.chatId, + identity: params.identity, + spec: params.spec, + organizationId: params.organizationId, + projectId: params.projectId, + projectRef: params.projectRef ?? null, + environmentId: params.environmentId, + userId: params.userId, + expiresAt: params.expiresAt, + investigateOnAttention: params.investigateOnAttention ?? false, + }) + .returning(); + + return { ok: true, watch: rows[0]! }; + }); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + // Lost the dedup race: the winner is already active. Look it up so the caller + // can point at the existing watch (null if it went terminal in the meantime). + const existing = await findActiveWatchByIdentity(db, params); + return { ok: false, error: "duplicate", existingId: existing?.id ?? null }; + } +} + +/** + * The guardrails, BEFORE anything is written — the `cap → dedup → immediate + * check` order of §4.4. + * + * Under the resolution model the immediate check can answer the request outright + * (a one-shot result block, no watch row at all), so the cap and the dedup have to + * be consulted before it runs: refusing at the 4th watch, or pointing at the watch + * that already covers this, must not depend on whether the condition happens to be + * true right now. + * + * Advisory only. It is a plain read, so it is NOT race-proof — {@link createWatch} + * re-applies both guardrails atomically under the per-chat lock and the partial + * unique index, and remains the authority. This exists so the common case gets the + * friendly answer without a row being written first. + */ +export async function precheckWatchCreation( + db: DashboardAgentDb, + params: { chatId: string; projectId: string; environmentId: string; identity: string } +): Promise< + | { ok: true } + | { ok: false; error: "limit_reached"; activeCount: number } + | { ok: false; error: "duplicate"; existingId: string } +> { + const active = await db + .select({ + id: watches.id, + identity: watches.identity, + projectId: watches.projectId, + environmentId: watches.environmentId, + }) + .from(watches) + .where(and(eq(watches.chatId, params.chatId), eq(watches.status, "active"))); + + const duplicate = active.find( + (w) => + w.identity === params.identity && + w.projectId === params.projectId && + w.environmentId === params.environmentId + ); + if (duplicate) return { ok: false, error: "duplicate", existingId: duplicate.id }; + + if (active.length >= MAX_ACTIVE_WATCHES_PER_CHAT) { + return { ok: false, error: "limit_reached", activeCount: active.length }; + } + + return { ok: true }; +} + +/** + * #13 The active watch on a given thing, if any — the dedup lookup behind + * `createWatch`, also useful on its own ("am I already watching this?"). + * Covered by `watches_chat_active_identity_key`. + */ +export async function findActiveWatchByIdentity( + db: DashboardAgentDb, + params: { chatId: string; projectId: string; environmentId: string; identity: string } +): Promise { + const rows = await db + .select() + .from(watches) + .where( + and( + eq(watches.chatId, params.chatId), + eq(watches.projectId, params.projectId), + eq(watches.environmentId, params.environmentId), + eq(watches.identity, params.identity), + eq(watches.status, "active") + ) + ) + .limit(1); + return rows[0] ?? null; +} + +/** #13 Load a watch by id. */ +export async function getWatch( + db: DashboardAgentDb, + params: { id: string } +): Promise { + const rows = await db.select().from(watches).where(eq(watches.id, params.id)).limit(1); + return rows[0] ?? null; +} + +/** + * #13 The chat's active watches — what the UI shows and what the guardrail + * counts. Covered by the partial `watches_chat_active_idx`. + */ +export async function listActiveWatchesForChat( + db: DashboardAgentDb, + params: { chatId: string } +): Promise { + return db + .select() + .from(watches) + .where(and(eq(watches.chatId, params.chatId), eq(watches.status, "active"))) + .orderBy(desc(watches.createdAt)); +} + +/** One active watch of a chat, in the shape the chip row needs. */ +export interface ActiveWatchSummary { + id: string; + chatId: string; + identity: string; + status: WatchStatus; + kind: string; + note: string; + checkEveryMinutes: number; + expiresAt: Date; + /** The last check's reason — lets a resolved banner tell "can no longer + * happen" (terminal_unsatisfied) apart from a plain timeout. */ + endedReason: string | null; + /** How it ended. NULL while active and for every cancellation. */ + resolution: WatchResolution | null; + /** What the resolving check observed — the other half of the headline. */ + observedOutcome: WatchObservedOutcome | null; +} + +/** + * #13 Watches for MANY chats in one query, keyed by chatId — the history list + * renders up to 50 chats and must not fan out a query per row. + * + * Returns every non-cancelled watch, not only the active ones: the chips + * filter to `active` client-side, while the wake banner needs the KIND of a + * watch that has already fired to pick its tone. + * + * Tenancy floor is the join, not the caller: the chats are re-scoped by + * `organizationId` + `userId` + not-deleted here, so a chat id from anywhere + * (including a client) can only ever match a chat this user owns. + */ +export async function listActiveWatchesForChats( + db: DashboardAgentDb, + params: { chatIds: string[]; organizationId: string; userId: string } +): Promise> { + if (params.chatIds.length === 0) return {}; + + const rows = await db + .select({ + id: watches.id, + chatId: watches.chatId, + identity: watches.identity, + status: watches.status, + spec: watches.spec, + expiresAt: watches.expiresAt, + lastResult: watches.lastResult, + resolution: watches.resolution, + observedOutcome: watches.observedOutcome, + }) + .from(watches) + .innerJoin(chats, eq(chats.id, watches.chatId)) + .where( + and( + inArray(watches.chatId, params.chatIds), + inArray(watches.status, ["active", "fired", "expired"]), + eq(chats.organizationId, params.organizationId), + eq(chats.userId, params.userId), + isNull(chats.deletedAt) + ) + ) + .orderBy(desc(watches.createdAt)); + + const byChat: Record = {}; + for (const row of rows) { + (byChat[row.chatId] ??= []).push({ + id: row.id, + chatId: row.chatId, + identity: row.identity, + status: row.status, + kind: row.spec.kind, + note: row.spec.note, + checkEveryMinutes: row.spec.checkEveryMinutes, + expiresAt: row.expiresAt, + endedReason: typeof row.lastResult?.reason === "string" ? row.lastResult.reason : null, + resolution: row.resolution, + observedOutcome: row.observedOutcome, + }); + } + return byChat; +} + +/** + * #13 How many watch wakes this user hasn't seen — what the launcher's dot shows + * while the panel is closed. + * + * A wake is a watch that resolved (`fired` or `expired`; a cancelled watch is + * never narrated) after the chat was last read. `last_read_at is null` means the + * chat was never opened since the column existed, so every wake in it is unread. + * The resolution time is `fired_at` for a fire and `last_checked_at` for an + * expiry — `transitionWatchCondition` writes both in the same statement. + * + * Tenancy floor is the join, same as `listActiveWatchesForChats`: org + user + + * not-deleted on the chat, so nothing outside this user's chats can be counted. + */ +export async function countUnreadWatchWakes( + db: DashboardAgentDb, + params: { organizationId: string; userId: string } +): Promise { + const rows = await db + .select({ count: sql`count(*)::int` }) + .from(watches) + .innerJoin(chats, eq(chats.id, watches.chatId)) + .where( + and( + inArray(watches.status, ["fired", "expired"]), + // Only a DELIVERED wake is a wake the user can open: between the terminal + // transition and the append to the chat there is no message to read yet, + // so signalling it would point at an empty conversation. + eq(watches.deliveryStatus, "delivered"), + eq(chats.organizationId, params.organizationId), + eq(chats.userId, params.userId), + isNull(chats.deletedAt), + sql`(${chats.lastReadAt} is null or coalesce(${watches.firedAt}, ${watches.lastCheckedAt}) > ${chats.lastReadAt})` + ) + ); + return rows[0]?.count ?? 0; +} + +/** An unread wake, as the dashboard toast narrates it. */ +export interface UnreadWatchWake { + watchId: string; + chatId: string; + outcome: "fired" | "expired"; + /** The watch's note, or its identity when the note is blank. */ + note: string; + /** When the watch resolved: `fired_at` for a fire, `last_checked_at` for an expiry. */ + firedAt: Date; + /** + * The three fields a surface needs to state the FACT rather than "Watch update" + * (§5.3): the kind and identity name the thing, the resolution and the observed + * outcome decide what happened to it. Frozen on the row by the resolving check, + * so the toast and the banner can never disagree. + */ + kind: string; + identity: string; + /** Null on a row written before the resolution model — the surface falls back. */ + resolution: WatchResolution | null; + observedOutcome: WatchObservedOutcome | null; +} + +// The toast fires one per wake, so a long-unopened panel doesn't need the whole +// backlog — enough to name the recent ones, and the count carries the rest. +const UNREAD_WAKE_LIST_LIMIT = 10; + +/** + * #13 The unread wakes themselves, newest first — what the dashboard toast reads + * from. Same wake definition and tenancy floor as {@link countUnreadWatchWakes}; + * this one returns rows instead of a total, capped at + * {@link UNREAD_WAKE_LIST_LIMIT}. + */ +export async function listUnreadWatchWakes( + db: DashboardAgentDb, + params: { organizationId: string; userId: string } +): Promise { + const resolvedAt = sql`coalesce(${watches.firedAt}, ${watches.lastCheckedAt})`; + + const rows = await db + .select({ + watchId: watches.id, + chatId: watches.chatId, + status: watches.status, + identity: watches.identity, + spec: watches.spec, + resolution: watches.resolution, + observedOutcome: watches.observedOutcome, + resolvedAt, + }) + .from(watches) + .innerJoin(chats, eq(chats.id, watches.chatId)) + .where( + and( + inArray(watches.status, ["fired", "expired"]), + // Only a DELIVERED wake is a wake the user can open: between the terminal + // transition and the append to the chat there is no message to read yet, + // so signalling it would point at an empty conversation. + eq(watches.deliveryStatus, "delivered"), + eq(chats.organizationId, params.organizationId), + eq(chats.userId, params.userId), + isNull(chats.deletedAt), + sql`(${chats.lastReadAt} is null or coalesce(${watches.firedAt}, ${watches.lastCheckedAt}) > ${chats.lastReadAt})` + ) + ) + .orderBy(desc(resolvedAt)) + .limit(UNREAD_WAKE_LIST_LIMIT); + + return rows.map((row) => ({ + watchId: row.watchId, + chatId: row.chatId, + // Narrowed by the `in` clause above; only these two statuses are wakes. + outcome: row.status as "fired" | "expired", + note: row.spec.note?.trim() || row.identity, + firedAt: new Date(row.resolvedAt), + kind: row.spec.kind, + identity: row.identity, + resolution: row.resolution, + observedOutcome: row.observedOutcome, + })); +} + +/** + * #13 WHICH chats have unread wakes — the history list sorts them first and + * highlights them. Same wake definition and tenancy floor as + * {@link countUnreadWatchWakes}; this one groups instead of totalling. + * + * Not scoped to the listed chat ids: the set is small (only chats with a + * resolved, unseen watch) and the caller is listing every chat the user owns + * anyway, so a second `in` clause would only narrow what the join already does. + */ +export async function listChatIdsWithUnreadWakes( + db: DashboardAgentDb, + params: { organizationId: string; userId: string } +): Promise> { + const rows = await db + .selectDistinct({ chatId: watches.chatId }) + .from(watches) + .innerJoin(chats, eq(chats.id, watches.chatId)) + .where( + and( + inArray(watches.status, ["fired", "expired"]), + // Only a DELIVERED wake is a wake the user can open: between the terminal + // transition and the append to the chat there is no message to read yet, + // so signalling it would point at an empty conversation. + eq(watches.deliveryStatus, "delivered"), + eq(chats.organizationId, params.organizationId), + eq(chats.userId, params.userId), + isNull(chats.deletedAt), + sql`(${chats.lastReadAt} is null or coalesce(${watches.firedAt}, ${watches.lastCheckedAt}) > ${chats.lastReadAt})` + ) + ); + return new Set(rows.map((row) => row.chatId)); +} + +/** The tenancy a chat belongs to. */ +export interface ChatWatchContext { + organizationId: string; +} + +/** + * #13 Ownership check for a chat, returning the org it belongs to: a live chat + * with this id owned by this user. + * + * Deliberately does NOT return a project/environment. The chat's stored + * `metadata.context` is a snapshot from chat creation, and a watch must be bound + * to the environment of the turn that asked for it — which comes from the + * authenticated request context, not from the row. The org is returned because it + * is immutable for a chat and is the tenancy floor its watches can't leave. + */ +export async function getChatWatchContext( + db: DashboardAgentDb, + params: { chatId: string; userId: string } +): Promise { + const rows = await db + .select({ organizationId: chats.organizationId }) + .from(chats) + .where( + and(eq(chats.id, params.chatId), eq(chats.userId, params.userId), isNull(chats.deletedAt)) + ) + .limit(1); + + const chat = rows[0]; + if (!chat) return null; + + return { organizationId: chat.organizationId }; +} + +/** + * #13 The watch RESOLVED. Atomic — only an `active` row transitions, so a check + * that resolves at the same moment the sweeper completes the window yields + * exactly one winner (the loser gets `null`). Every resolution notifies, so + * `deliveryStatus` becomes `pending`. + * + * One statement writes all three halves of the answer — the `resolution`, the + * `observedOutcome`, and the frozen `lastResult` facts. That atomicity is what + * lets §7.5 hold: delivery never re-reads the source to reconstruct what + * happened, so a retry cannot rebuild a different headline, and banner, toast, + * email and narration all share one set of facts. + * + * `status` is derived, never passed: it is the two-value WIRE encoding of the + * resolution (§7.5 binding), so no caller can put a status on the row that + * disagrees with the resolution it recorded. + */ +export async function transitionWatchCondition( + db: DashboardAgentDb, + params: { + id: string; + resolution: WatchResolution; + observedOutcome?: WatchObservedOutcome | null; + lastResult?: Record | null; + } +): Promise { + const status = watchResolutionToWireStatus(params.resolution); + const rows = await db + .update(watches) + .set({ + status, + resolution: params.resolution, + deliveryStatus: "pending", + lastCheckedAt: sql`now()`, + firedAt: status === "fired" ? sql`now()` : null, + ...(params.observedOutcome !== undefined ? { observedOutcome: params.observedOutcome } : {}), + ...(params.lastResult !== undefined ? { lastResult: params.lastResult } : {}), + }) + .where(and(eq(watches.id, params.id), eq(watches.status, "active"))) + .returning(); + return rows[0] ?? null; +} + +/** + * #13 Cancel an active watch. Cancellation is never notified, so `deliveryStatus` + * stays `not_required`. Atomic guard on `active`: a watch that already fired keeps + * its outcome (and its pending notification) and this is a no-op returning `null`. + */ +export async function cancelWatch( + db: DashboardAgentDb, + params: { id: string; reason: WatchCancelReason } +): Promise { + const rows = await db + .update(watches) + .set({ + status: "cancelled", + cancelReason: params.reason, + cancelledAt: sql`now()`, + deliveryStatus: "not_required", + }) + .where(and(eq(watches.id, params.id), eq(watches.status, "active"))) + .returning(); + return rows[0] ?? null; +} + +/** + * #13 Cancel every active watch of a chat — chat deletion, or the user losing + * access to the project the watches were created against. + */ +export async function cancelActiveWatchesForChat( + db: DashboardAgentDbOrTx, + params: { chatId: string; reason: WatchCancelReason } +): Promise { + return db + .update(watches) + .set({ + status: "cancelled", + cancelReason: params.reason, + cancelledAt: sql`now()`, + deliveryStatus: "not_required", + }) + .where(and(eq(watches.chatId, params.chatId), eq(watches.status, "active"))) + .returning(); +} + +/** + * How long a `delivering` claim is respected before the wake is considered + * abandoned and may be claimed again. Longer than a delivery takes (seconds), so + * the only rows it releases are ones whose deliverer really died. + */ +export const WATCH_DELIVERY_CLAIM_STALE_MS = 5 * 60 * 1000; + +/** A delivery claim: the row as claimed, plus the token that owns the claim. */ +export interface WatchDeliveryClaim { + watch: Watch; + /** + * The fencing token. {@link releaseWatchDelivery} and {@link markWatchDelivered} + * only act while the row still carries it, so a claim that has been taken over + * can't be released or completed by its previous owner. + */ + claimId: string; +} + +/** + * #13 Claim the right to deliver a resolved watch's wake — the atomic gate that + * makes "exactly one wake" true even with two deliverers running at once. + * + * A stable action id dedups a wake only through a read-then-write on the + * transcript, which two concurrent appends can interleave through. So the claim + * lives here instead: `pending → delivering` in one statement, and only the row it + * returns may append. The loser gets `null` and delivers nothing. + * + * A claim is not a lease that has to be renewed: {@link releaseWatchDelivery} + * hands it back when the append fails, and a claim left behind by a deliverer that + * died is re-claimable once it is older than `staleBefore` — otherwise a crash + * between the claim and `markWatchDelivered` would strand the wake forever. + * + * Every claim writes a NEW `deliveryClaimId`, which is what makes the takeover + * safe: the status alone can't say WHOSE claim is in the row, so a deliverer that + * hung past the stale window and then woke up would otherwise release (or complete) + * the claim that replaced it, and a third deliverer would append in parallel with + * the second. The token is required by both of those writes, so the old owner's + * calls are no-ops. + */ +export async function claimWatchDelivery( + db: DashboardAgentDb, + params: { id: string; staleBefore: Date } +): Promise { + const claimId = generateWatchDeliveryClaimId(); + const rows = await db + .update(watches) + .set({ deliveryStatus: "delivering", deliveryClaimedAt: sql`now()`, deliveryClaimId: claimId }) + .where( + and( + eq(watches.id, params.id), + sql`(${watches.deliveryStatus} = 'pending' or (${watches.deliveryStatus} = 'delivering' and coalesce(${watches.deliveryClaimedAt}, ${watches.createdAt}) <= ${params.staleBefore.toISOString()}::timestamptz))` + ) + ) + .returning(); + const watch = rows[0]; + return watch ? { watch, claimId } : null; +} + +/** + * #13 Give a delivery claim back, after an append that failed: the wake is owed + * again, so the invocation's own retry (or another deliverer) can pick it up + * without waiting out the stale window. + * + * Fenced on `claimId`: only the deliverer that still holds the claim releases it, + * so a late release from a taken-over owner can't hand somebody else's in-flight + * claim back to `pending`. Guarded on `delivering` too, so it can never un-deliver + * a wake that landed. + */ +export async function releaseWatchDelivery( + db: DashboardAgentDb, + params: { id: string; claimId: string } +): Promise { + const rows = await db + .update(watches) + .set({ deliveryStatus: "pending", deliveryClaimedAt: null, deliveryClaimId: null }) + .where( + and( + eq(watches.id, params.id), + eq(watches.deliveryClaimId, params.claimId), + eq(watches.deliveryStatus, "delivering") + ) + ) + .returning(); + return rows[0] ?? null; +} + +/** + * #13 The outcome notification went out, so the row is closed out. + * + * Two callers, two guards: + * + * - A deliverer that claimed the wake passes its `claimId`, and the mark lands only + * while the row still carries that claim. A stale takeover replaced the token, so + * the old owner's late mark can't complete the new owner's delivery. + * - The one path that never claims (an outcome resolved inline with nothing to + * narrate later) passes no `claimId` and marks a `pending` row. Deliberately not + * `delivering`: an unfenced mark must not be able to finish a claim it doesn't own. + * + * Either way a repeat is a no-op, so a retried delivery can't reset `deliveredAt`. + */ +export async function markWatchDelivered( + db: DashboardAgentDb, + params: { id: string; claimId?: string } +): Promise { + const rows = await db + .update(watches) + .set({ deliveryStatus: "delivered", deliveredAt: sql`now()` }) + .where( + and( + eq(watches.id, params.id), + params.claimId + ? and( + eq(watches.deliveryClaimId, params.claimId), + eq(watches.deliveryStatus, "delivering") + ) + : eq(watches.deliveryStatus, "pending") + ) + ) + .returning(); + return rows[0] ?? null; +} + +/** + * #13 Claim a tick GENERATION for a watch — the one and only writer of + * `tickCount`. + * + * A tick invocation carries its generation in its payload and claims it here. The + * claim is resumable: it lands when the row is still on the previous generation (a + * fresh tick) OR already on this one (a retry of the invocation that owns this + * generation, which crashed somewhere mid-tick). It does NOT land when the row is + * further ahead — the successor generation already ran, so this invocation is a + * late duplicate with nothing left to do, and gets `null`. + * + * Resuming is what keeps a crash from killing the chain. The generation lives in + * the payload and the successor's idempotency key (`watch:{id}:tick:{n+1}`) is a + * pure function of it, so re-running a whole generation is safe: the successor + * trigger dedups on that key, the check record is an overwrite, the terminal + * transition is guarded on `active`, and the wake dedups on its action id. A claim + * that refused to resume would leave the chain with nobody to schedule the next + * generation, and the watch would sit active and unchecked until its deadline. + * + * Guarded on `active` too: a terminal watch is never ticked again. + * + * Deliberately does NOT touch `lastCheckedAt`: claiming a generation is not an + * observation, and a claim whose check then failed to run would otherwise date the + * watch's last observation to it — the expiry narration reports that timestamp as + * "last observed". `lastCheckedAt` is written only where a result is written with + * it ({@link recordWatchCheck}, {@link transitionWatchCondition}), so the timestamp + * and the observation it belongs to always agree. + */ +export async function claimWatchTick( + db: DashboardAgentDb, + params: { id: string; generation: number } +): Promise { + const rows = await db + .update(watches) + .set({ tickCount: params.generation }) + .where( + and( + eq(watches.id, params.id), + eq(watches.status, "active"), + inArray(watches.tickCount, [params.generation - 1, params.generation]) + ) + ) + .returning(); + return rows[0] ?? null; +} + +/** + * #13 Record what a check observed: `lastCheckedAt` plus the `lastResult` the + * notification reads. Deliberately does NOT touch `tickCount` — the generation is + * claimed by {@link claimWatchTick} alone, so the counter has a single writer and + * this can be called by both the check endpoint and the tick without either of + * them advancing the chain. Guarded on `active`, so a concurrent fire/expire wins + * and this no-ops. + */ +export async function recordWatchCheck( + db: DashboardAgentDb, + params: { + id: string; + lastResult?: Record | null; + /** Override the check timestamp; defaults to `now()`. */ + lastCheckedAt?: Date; + } +): Promise<{ tickCount: number; lastCheckedAt: Date | null } | null> { + const rows = await db + .update(watches) + .set({ + lastCheckedAt: params.lastCheckedAt ?? sql`now()`, + ...(params.lastResult !== undefined ? { lastResult: params.lastResult } : {}), + }) + .where(and(eq(watches.id, params.id), eq(watches.status, "active"))) + .returning({ tickCount: watches.tickCount, lastCheckedAt: watches.lastCheckedAt }); + return rows[0] ?? null; +} + +/** + * #13 Sweep: terminal watches whose delivery is still owed. + * + * The other half of the backstop, and the one `listExpiredActiveWatches` cannot + * see: a row that has already been resolved (so it is no longer `active`) but + * whose wake never landed — the session append failed, the run that owned it died + * between the transition and the append, or the outcome was resolved inline and + * the turn that was going to narrate it never finished. Without this the row sits + * `pending` forever, and the wake is simply lost. + * + * `olderThan` is a grace window on the resolution time (`fired_at` for a fire, + * `last_checked_at` for an expiry): the normal delivery happens within seconds, so + * only rows that have been owed for a while are recovered, and the recovery can't + * race the path that is still mid-delivery. + * + * A row mid-delivery (`delivering`) is owed too, but only once its claim is older + * than the same window: that is a deliverer that died between claiming the wake and + * marking it delivered, and nothing else would ever pick it up. + * + * Deleted chats are excluded — there is nowhere to deliver a wake in a + * conversation the user can no longer open (deleting a chat cancels its active + * watches, so this only ever skips one that resolved just before the delete). + */ +export async function listWatchesAwaitingDelivery( + db: DashboardAgentDb, + params: { olderThan: Date; limit?: number } +): Promise { + const olderThan = sql`${params.olderThan.toISOString()}::timestamptz`; + const rows = await db + .select({ watch: watches }) + .from(watches) + .innerJoin(chats, eq(chats.id, watches.chatId)) + .where( + and( + inArray(watches.status, ["fired", "expired"]), + sql`(${watches.deliveryStatus} = 'pending' or (${watches.deliveryStatus} = 'delivering' and coalesce(${watches.deliveryClaimedAt}, ${watches.createdAt}) <= ${olderThan}))`, + isNull(chats.deletedAt), + sql`coalesce(${watches.firedAt}, ${watches.lastCheckedAt}) <= ${olderThan}` + ) + ) + .orderBy(sql`coalesce(${watches.firedAt}, ${watches.lastCheckedAt})`) + .limit(params.limit ?? 100); + + return rows.map((row) => row.watch); +} + +/** + * #13 Sweep: active watches whose deadline has passed, oldest first. Callers run + * the final boundary evaluation and resolve these via + * `transitionWatchCondition` — which may still be `condition_met` (§7.4). + * Covered by `watches_status_expires_idx`. + */ +export async function listExpiredActiveWatches( + db: DashboardAgentDb, + params: { now?: Date; limit?: number } = {} +): Promise { + return db + .select() + .from(watches) + .where( + and( + eq(watches.status, "active"), + // The bind has to be a string: postgres-js won't serialize a Date into a + // raw `sql` fragment (it silently worked only while nobody passed `now`). + params.now + ? sql`${watches.expiresAt} <= ${params.now.toISOString()}::timestamptz` + : sql`${watches.expiresAt} <= now()` + ) + ) + .orderBy(watches.expiresAt) + .limit(params.limit ?? 100); +} diff --git a/internal-packages/dashboard-agent-db/src/schema.ts b/internal-packages/dashboard-agent-db/src/schema.ts index 14a690cfe8a..fb59eb0cbea 100644 --- a/internal-packages/dashboard-agent-db/src/schema.ts +++ b/internal-packages/dashboard-agent-db/src/schema.ts @@ -9,7 +9,13 @@ import { smallint, text, timestamp, + uniqueIndex, } from "drizzle-orm/pg-core"; +import type { + WatchObservedOutcome, + WatchResolution, + WatchSpec, +} from "@internal/dashboard-agent-contracts"; /** * All dashboard-agent tables live in a dedicated Postgres schema. In cloud this @@ -46,7 +52,9 @@ export const chats = dashboardAgentSchema.table( // Project/env context + model choice + page snapshot. Flexible by design. metadata: jsonb("metadata").$type>().notNull().default({}), pinnedAt: timestamp("pinned_at", { withTimezone: true }), - // When the user last had this chat in front of them. NULL means never read. + // When the user last had this chat in front of them. NULL means never read, + // so everything in it counts as unread — the launcher's dot compares watch + // wakes against this. lastReadAt: timestamp("last_read_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }), lastMessageAt: timestamp("last_message_at", { withTimezone: true }), @@ -182,6 +190,140 @@ export const investigations = dashboardAgentSchema.table( ] ); +/** `active` is the only non-terminal status; the other three are immutable. */ +export type WatchStatus = "active" | "fired" | "expired" | "cancelled"; +/** + * `not_required` while active and for every cancelled outcome — only fired/expired + * notify. `delivering` is the in-flight claim: one deliverer at a time, so two + * concurrent invocations can't both wake the chat (see `claimWatchDelivery`). + */ +export type WatchDeliveryStatus = "not_required" | "pending" | "delivering" | "delivered"; +/** + * `scheduling_failed` is the one cancellation the system issues on its own: the + * first tick could not be scheduled, so nothing would ever check this watch. + * Silent like every other cancellation — no resolution, no wake. + */ +export type WatchCancelReason = "user" | "access_revoked" | "chat_deleted" | "scheduling_failed"; + +/** + * The persisted spec adds a server-set `since` to the caller's spec. It's the + * watch's creation time (ISO), used by `error_recurrence` so a recurrence check + * can't match errors that predate the watch. Stored inside the JSONB rather than + * as a column because it's part of the check's input, not watch lifecycle state. + */ +export type PersistedWatchSpec = WatchSpec & { since?: string }; + +/** + * One row per watch — "tell me when X happens", checked by a periodic task. + * + * The initiating identity (`organizationId` / `projectId` / `environmentId` / + * `userId`) is a **snapshot taken at creation and never updated**: a watch fires + * with exactly the access its creator had, so a later membership change can only + * cancel it (`cancel_reason = 'access_revoked'`), never silently widen its scope. + * These are main-DB ids, FK-free (cross-db). + * + * `identity` is the caller-computed dedup key for the watched thing (e.g. the run + * id or queue being watched) — its own column so the "already watching this" + * lookup is a plain indexed query instead of a JSONB dig. + * + * Status/delivery transitions are guarded in the query layer with + * `WHERE status = 'active' … RETURNING`, not by DB constraints, so a concurrent + * fire/expire/cancel resolves to exactly one winner. + */ +export const watches = dashboardAgentSchema.table( + "watches", + { + id: text("id").primaryKey(), // = watchId (`watch_…`) + chatId: text("chat_id").notNull(), // = chats.id + // Dedup key component: what is being watched, as a string. + identity: text("identity").notNull(), + spec: jsonb("spec").$type().notNull(), + status: text("status").$type().notNull().default("active"), + deliveryStatus: text("delivery_status") + .$type() + .notNull() + .default("not_required"), + cancelReason: text("cancel_reason").$type(), + /** + * HOW the watch ended, in the model's own three values — `condition_met`, + * `window_completed`, `condition_impossible`. `status` above stays as the + * two-value transport encoding (§7.5) so persisted wake ids and dedup keys + * remain valid; this column is the meaning. NULL while active and on every + * cancellation (a cancelled watch has no resolution). + */ + resolution: text("resolution").$type(), + /** + * WHAT the resolving check observed — the run's final status, the depth, the + * recurrence count. Written in the SAME statement as `resolution` and + * `lastResult`, so delivery never re-reads the source to reconstruct what + * happened (§7.5) and a retry cannot rebuild a different headline. + */ + observedOutcome: jsonb("observed_outcome").$type(), + /** + * The one resolution ACTION the user consented to at creation (§6): after an + * attention outcome, the wake turn may open an investigation without asking. + * A flag on the row, not part of the spec and never part of `identity` — two + * watches on the same thing are the same watch whatever they do afterwards. + * Default false: the agent may only set it when the user asked for it. + */ + investigateOnAttention: boolean("investigate_on_attention").notNull().default(false), + // Immutable initiating identity — snapshot at creation. + organizationId: text("organization_id").notNull(), + projectId: text("project_id").notNull(), + /** + * The project's EXTERNAL ref (`proj_…`) — the same identifier the `trigger://` + * scheme and the investigations table use. Carried on the row because a wake + * has to scope an investigation exactly as a turn would, and the agent can't + * translate an internal project id (it has no access to the main database). + * Nullable: rows created before this column simply don't carry it. + */ + projectRef: text("project_ref"), + environmentId: text("environment_id").notNull(), + userId: text("user_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }), + firedAt: timestamp("fired_at", { withTimezone: true }), + // When the current deliverer claimed the wake. A claim older than the + // delivery grace is treated as abandoned and may be re-claimed. + deliveryClaimedAt: timestamp("delivery_claimed_at", { withTimezone: true }), + // WHICH deliverer holds the claim — the fencing token. Written fresh on every + // claim (including a stale takeover), and required by the release / delivered + // marks, so a deliverer that comes back from the dead can't release or complete + // the claim that replaced its own. + deliveryClaimId: text("delivery_claim_id"), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + cancelledAt: timestamp("cancelled_at", { withTimezone: true }), + // The last check's output; on fire/expire it's the payload the notification uses. + lastResult: jsonb("last_result").$type>(), + // Ticks so far. Check idempotency keys are `watch:{id}:tick:{n}`. + tickCount: integer("tick_count").notNull().default(0), + }, + (t) => [ + index("watches_chat_idx").on(t.chatId), + // Guardrails + dedup: "the active watches in this chat" (max 3, and is this + // thing already watched). Partial so terminal rows never bloat the hot path. + // + // UNIQUE is the actual dedup guarantee: a read-then-insert check can't be + // race-proof under READ COMMITTED (two transactions both see no duplicate and + // both insert), so the constraint has to live in the DB. Partial on `active` + // because re-watching the same thing after a watch fired must be allowed. + // Leading `chat_id` means this also serves the "active watches of this chat" + // lookup, so no separate non-unique partial index is needed. + uniqueIndex("watches_chat_active_identity_key") + .on(t.chatId, t.projectId, t.environmentId, t.identity) + .where(sql`${t.status} = 'active'`), + // Sweep: active watches due to be checked / past their expiry. + index("watches_status_expires_idx").on(t.status, t.expiresAt), + // The other half of the sweep: resolved watches whose wake is still owed — + // never claimed, or claimed by a deliverer that died mid-flight. Partial, + // because "owed" is a handful of rows at any moment. + index("watches_pending_delivery_idx") + .on(t.firedAt, t.lastCheckedAt) + .where(sql`${t.deliveryStatus} in ('pending', 'delivering')`), + ] +); + export type Chat = typeof chats.$inferSelect; export type NewChat = typeof chats.$inferInsert; export type ChatSession = typeof chatSessions.$inferSelect; @@ -190,3 +332,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 Watch = typeof watches.$inferSelect; +export type NewWatch = typeof watches.$inferInsert; diff --git a/internal-packages/dashboard-agent/GUIDEBOOK.md b/internal-packages/dashboard-agent/GUIDEBOOK.md index 6d5519ecc3a..0ea5c538ba8 100644 --- a/internal-packages/dashboard-agent/GUIDEBOOK.md +++ b/internal-packages/dashboard-agent/GUIDEBOOK.md @@ -2,8 +2,9 @@ An AI assistant in a side panel on every dashboard page. It reads your runs, errors, queues, deploys and health through the same APIs you use, answers in -place and renders rich cards. Read-only by design — the only thing it ever -creates is (with your explicit yes) an email alert subscription. +place, renders rich cards, and can keep watching things after the conversation +ends. Read-only by design — the only things it ever creates are its own +watches and (with your explicit yes) an email alert subscription. Branch: `feat/dashboard-agent-flows`. @@ -53,7 +54,7 @@ pnpm --filter webapp run db:seed:agent-examples -- --degrade # prod goes crit pnpm --filter webapp run db:seed:agent-examples -- --recover # prod recovers ``` -That pair is how you move the health report between crit and ok on demand. +That pair is how you demo the whole watch-fires-alert loop to yourself. --- @@ -69,11 +70,19 @@ inconclusive and what to check next. *Investigate* buttons appear on failed runs, error pages, and backed-up queues. With a connected GitHub repo it reads your actual source at the deployed commit and cites file:line. -**Alerts** — a standing email subscription on the standard alert channels, -created only if you say yes: it shows up on the project's Alerts page, with -one-click unsubscribe in every email. Ask "what alerts do I have?" / "turn off -the email alert" — the agent manages them. Plan/flag gated; the in-dashboard -notification is always on. +**Watch** — "tell me when this run starts", "ping me if this error comes +back", the *Watch recovery* button on a degraded health report. A durable +condition the platform checks on a schedule (no LLM in the checks), which +wakes the chat with the outcome. Fires once, expires within 24h, max 3 per +chat. Five kinds: run start / run finished / backlog drain / error recurrence +/ health recovery. + +**Alerts** — when a watch is created (or fires) without a subscription, the +agent offers an email alert — one line, created only if you say yes. Standing +subscription on the standard alert channels: shows up on the project's Alerts +page, fires for every watch fire, one-click unsubscribe in every email. Ask +"what alerts do I have?" / "turn off the email alert" — the agent manages +them. Plan/flag gated; the in-dashboard notification is always on. **Reports** — "is anything wrong right now?" renders the deterministic health report as a card: severity, metric grid with sparklines, who owns the problem, @@ -92,6 +101,7 @@ scenarios worth trying first. | Say | It does | | --- | --- | | ⭐ "Why did this run fail?" / *Investigate* | full investigation card with tested hypotheses and cited evidence | +| ⭐ "Tell me when the backlog drains" / "…when this run starts" | durable watch that wakes the chat (and emails you, if subscribed) | | ⭐ "Is anything wrong right now?" | the deterministic health report as a card | | ⭐ "Show me the failing code" (repo connected) | reads your source at the run's deployed commit, cites file:line | | "Where am I?" / "what is this page showing?" | explains the current page — it always knows where you are | @@ -103,24 +113,35 @@ scenarios worth trying first. | "What's deployed right now?" / "did the last deploy cause this?" | deploy list, current version, run→commit correlation | | "What tasks does this project have?" | the task list with file paths | | "How do retries work?" / "how do I set a concurrency limit?" | docs answer with source links | -| "What alerts do I have?" / "turn off the email alert" | lists and manages your alert subscriptions | +| "What alerts do I have?" / "turn off the email alert" | lists and manages your watch-result subscriptions | | "This looks broken, can you flag it to support?" | files the context to the support channel | ## Where the UI got updates +- **Chat button (page header)** — unread dot when a watch woke a chat you + haven't read. +- **Persistent toast** — a wake while you're anywhere in the dashboard raises + a notification that stays until you close it. - **Chat history** — unread chats first and highlighted; per-chat status icon - left of the title (spinner = agent working, magnifier = investigation in - progress; hover for which). + left of the title (spinner = agent working / watch active, magnifier = + investigation in progress; hover for which). +- **In the transcript** — a watch's result opens with a banner that states the + fact ("email-sends queue drained", "Run abc123 failed", "Health recovered"), + toned by what actually happened rather than by which kind of watch it was; + watch chips under the composer show live watches with cancel; clicking a + card's watch button posts a visible request the agent answers. - **Report cards** — terminal-style skin, metric grid with sparklines, *Next steps* footer with real buttons (docs entries always get the docs button). +- **Alerts page** — the new "Dashboard agent watches" alert type on standard + channels (email / Slack / webhook). ## Suggested prompts An empty chat offers up to five, picked from where you are: a promoted one -(product-controlled), *Investigate* for the failure on screen, an -explain-this-page one, and a docs one. +(product-controlled), *Investigate* for the failure on screen, a watch for +the thing in front of you, an explain-this-page one, and a docs one. -## The demo script — three acts, ~10 minutes +## The demo script — four acts, ~15 minutes Run `-- --degrade` RIGHT BEFORE the demo, not ahead of time — a degradation left running for a while blends into the baselines and starts reading as @@ -136,18 +157,31 @@ the act-four commands. 3. *"Is anything wrong right now?"* → the terminal-style report card: crit, pinned concurrency, sparklines, "not your code", a *Next steps* row of real buttons. -4. **Investigate**: open the failing `send-order-receipt` error → - *Investigate* → a live card: hypotheses tested in front of you, a concluded - verdict citing runs, spans, and the deploy (a 429 rate limit). -5. Bonus, same chat: *"Show me the failing code"* — file:line at the deployed +4. Click **[Watch recovery]** → your request appears in the chat → the agent + confirms: checks every 5 minutes, fires once, gives up after N hours — and + offers an email alert. Say *"yes"* and the channel appears on the Alerts + page. +5. While the watch ticks — **Investigate**: open the failing + `send-order-receipt` error → *Investigate* → a live card: hypotheses tested + in front of you, a concluded verdict citing runs, spans, and the deploy + (a 429 rate limit). +6. Bonus, same chat: *"Show me the failing code"* — file:line at the deployed commit. -**Act 3 — data and knowledge (the finale)** -6. *"How many runs failed yesterday, by task? Chart it"* — TRQL + a live chart. -7. *"Did the last deploy cause this?"* — run → commit → deploy correlation. -8. *"How do retries work?"* — a docs answer with source buttons. -9. Curtain: *"What alerts do I have?"* → the list; *"turn it off"* → - unsubscribed right from the chat. +**Act 3 — data and knowledge (the interlude)** +7. *"How many runs failed yesterday, by task? Chart it"* — TRQL + a live chart. +8. *"Did the last deploy cause this?"* — run → commit → deploy correlation. +9. *"How do retries work?"* — a docs answer with source buttons. + +**Act 4 — the finale: it comes back on its own** +10. In the terminal: `pnpm --filter webapp run db:seed:agent-examples -- --recover`. + Close the panel and wander the dashboard. +11. Within ~5 minutes, unprompted: a persistent "Watch update" toast, a dot on + the chat button, the chat on top of History highlighted — inside, the green + **"Watch update — all clear"** banner — and the email in Mailpit + (localhost:8025) with the same headline and one-click unsubscribe. +12. Curtain: *"What alerts do I have?"* → the list; *"turn it off"* → + unsubscribed right from the chat. Safety nets: History ships seeded example conversations (browsable without spending a token), and every card state lives in the gallery at @@ -158,8 +192,10 @@ spending a token), and every card state lives in the gallery at | Where | Say / click | You'll see | | --- | --- | --- | | prod runs page | "Is anything wrong right now?" | degraded report card (run `--degrade` first) | -| terminal | `--recover` | the report turns ok | +| that card | *Watch recovery* → confirm the alert offer | visible request, chip, alert channel on /alerts | +| terminal | `--recover` | report turns ok; within ~5 min the chat wakes green + email in Mailpit | | a failed run | *Investigate* | live investigation card, concluded with evidence | +| an error page | "Ping me if this error comes back" | pending watch; recurrence wakes the chat | | anywhere | "How many runs failed yesterday, by task?" | TRQL answer, chart on request | | anywhere | "What alerts do I have?" | the agent lists your subscriptions | @@ -169,7 +205,7 @@ readable without spending a token — and there's a component gallery at ## What it will not do -- Write anything beyond an alert you explicitly approved. +- Write anything beyond its own watches and an alert you explicitly approved. - Invent numbers or claim something doesn't exist beyond a truncated page. - Trust a report whose telemetry is stale. diff --git a/internal-packages/dashboard-agent/PLAYBOOK.md b/internal-packages/dashboard-agent/PLAYBOOK.md index b19f2999ccf..cf55ac834ff 100644 --- a/internal-packages/dashboard-agent/PLAYBOOK.md +++ b/internal-packages/dashboard-agent/PLAYBOOK.md @@ -47,10 +47,10 @@ The fixtures behind the gallery live in | Rendered by | Cases | | --- | --- | | Production components | messages, text/markdown, reasoning, tool rows, `diagnosis`, `chart` and report view blocks, context banner, suggested prompts, composer, history list | -| Gallery-only stand-ins | investigation card (no block type until M5), prompt row with promoted/dismissed states, navigate bubble, chart card with canned rows | +| Gallery-only stand-ins | investigation card (no block type until M5), watch chips, prompt row with promoted/dismissed states, navigate bubble, chart card with canned rows | The stored conversations only carry what the production renderer handles, so the -beats that have no block type yet (investigations, intents) are +beats that have no block type yet (investigations, watches, intents) are assistant text there, and the cards themselves are reviewed in the gallery. The stand-in cards are the ones to review hardest: **this review freezes @@ -93,6 +93,13 @@ should return for it. The chat shows only the failed-run page — the other page kinds and the post-dismissal row are in the state gallery, because a chat is one story and stacked variants of one row read as a bug. +## Watch + +| Open | You should see | Feedback wanted | +| --- | --- | --- | +| `Tell me when the backlog drains` | A watch intent, the chip row under the banner (`send-order-receipt`, `backlog-drain`), then an unprompted wake narration minutes later. | Does an unprompted message need more framing than the note above it? Is the chip row the right home for watches? | +| `Watch for that error recurring` | Chips in all four states (watching / fired / expired / cancelled), an expiry narration, the **couldn't verify at expiry** variant, and a cancel confirmation. The cancel control on an active chip is a labelled icon button (“Cancel the backlog-drain watch”, tooltip on hover) and is intercepted. | Is the "couldn't verify" wording clearly different from "it didn't happen"? Should an expired watch offer to renew itself? | + ## Reports | Open | You should see | Feedback wanted | diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.eval.ts b/internal-packages/dashboard-agent/src/dashboard-agent.eval.ts index b01a74fc2a8..562ea18603c 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.eval.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.eval.ts @@ -267,6 +267,16 @@ const FIXTURES: Record = { pullRequestTitle: "Batch the receipt sends", }, }, + // A scheduled watch, as the host returns it: the id, the thing it watches, and + // when it gives up. No immediate outcome, so the answer must promise a message. + schedule_watch: { + watchId: "watch_eval1", + identity: "run_finished:run_a1", + status: "active", + expiresAt: "2026-01-02T01:00:00.000Z", + checkEveryMinutes: 1, + watching: true, + }, search_docs: { results: "batchTrigger() triggers many runs of the same task in one call. It takes an array of payloads and returns a batch handle; use batchTriggerAndWait() inside a task to wait for all of them.", @@ -626,9 +636,12 @@ const TOOL_CASES: Array<{ question: string; expect: string | string[] }> = [ { question: "How do I use batchTrigger?", expect: "search_docs" }, { question: "How deep is the email queue?", expect: "get_queue" }, { question: "What was deployed recently?", expect: "list_deploys" }, + // M6: "tell me when" is a watch, never a poll. + // Named id on purpose: "this run" would legitimately open on get_current_page. + { question: "Tell me when run run_a1 finishes.", expect: "schedule_watch" }, ]; -// 20 cases; tolerate ~3 misses. A single nondeterministic miss shouldn't red the +// 21 cases; tolerate ~3 misses. A single nondeterministic miss shouldn't red the // suite, a trend should. const TOOL_SELECTION_THRESHOLD = 0.83; diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.test.ts b/internal-packages/dashboard-agent/src/dashboard-agent.test.ts index 8759713744d..58d0861a9ab 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.test.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.test.ts @@ -393,6 +393,346 @@ describe("dashboardAgent (mock harness)", () => { }); }); +// --------------------------------------------------------------------------- +// Watch wakes — an action, not a turn +// --------------------------------------------------------------------------- + +describe("watch wake narration", () => { + let harness: MockChatAgentHarness | undefined; + + afterEach(async () => { + await harness?.close(); + harness = undefined; + }); + + const WAKE = { + type: "watch.fired" as const, + id: "watch:watch_1:fired", + watchId: "watch_1", + identity: "backlog_drain:task/send-receipt", + spec: { + kind: "backlog_drain", + queue: "task/send-receipt", + checkEveryMinutes: 5, + maxHours: 2, + note: "tell me when the backlog drains", + }, + facts: { pending: 0, peakPending: 412, drainedAt: "2026-01-01T12:40:00.000Z" }, + }; + + it("narrates the wake once and persists it, and a redelivered wake narrates nothing", async () => { + const { store, calls } = fakeStore(); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake", + clientData: CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, mockModel([textStep("The backlog drained — 0 pending now.")])); + }, + }); + + const first = await harness.sendAction(WAKE); + expect(collectText(first.chunks)).toBe("The backlog drained — 0 pending now."); + + // The streamed message carries the SAME id the read-model copy is persisted + // under — the panel merges live stream and loaded history by message id, so + // two ids for one narration would render it twice. + const startChunk = first.chunks.find( + (chunk) => (chunk as { type?: string }).type === "start" + ) as { messageId?: string } | undefined; + expect(startChunk?.messageId).toBe("wake:watch:watch_1:fired"); + + // An action is not a turn: no turn persistence ran, but the narration is in + // the display read-model under an id derived from the action. + expect(calls.persistTurn).toHaveLength(0); + expect(calls.persistMessages).toHaveLength(1); + const persisted = (calls.persistMessages[0] as { messages: UIMessage[] }).messages; + expect(persisted).toHaveLength(1); + expect(persisted[0]).toMatchObject({ id: "wake:watch:watch_1:fired", role: "assistant" }); + + // Same action id again (the watcher retried after appending): deduped. + const second = await harness.sendAction(WAKE); + expect(collectText(second.chunks)).toBe(""); + expect(calls.persistMessages).toHaveLength(1); + }); + + /** + * A model that records the prompt it was asked with, so the wake's framing can + * be asserted directly. The narration IS the prompt's job — what it must never + * say is as load-bearing as what it must. + */ + function recordingModel(text: string) { + const prompts: unknown[] = []; + const model = new MockLanguageModelV3({ + doStream: async (options) => { + prompts.push(options.prompt); + return { stream: simulateReadableStream({ chunks: textStep(text) }) }; + }, + doGenerate: async () => ({ + content: [{ type: "text", text }], + finishReason: { unified: "stop", raw: "stop" }, + usage: USAGE, + warnings: [], + }), + }); + return { model, prompts }; + } + + function wakeText(prompts: unknown[]): string { + return JSON.stringify(prompts); + } + + // §4.2 / §7.7: the narration speaks the resolution model, and a completed + // window is an ANSWER — never "the watch expired with nothing to say". + it("frames a completed window as the answer the user asked for", async () => { + const { store } = fakeStore(); + const { model, prompts } = recordingModel("The backlog still hasn't drained — 42 pending."); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_window", + clientData: CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, model); + }, + }); + + await harness.sendAction({ + ...WAKE, + type: "watch.expired" as const, + id: "watch:watch_1:expired", + resolution: "window_completed" as const, + observed: { kind: "backlog_drain", verified: true, depth: 42 }, + facts: { verified: true, reason: "not_met_by_expiry", depth: 42 }, + }); + + const prompt = wakeText(prompts); + expect(prompt).toContain("window_completed"); + expect(prompt).toContain("this is the answer the user asked for"); + expect(prompt).toContain("reports once"); + // The wire encoding is transport, not vocabulary (§7.5). + expect(prompt).not.toContain("the watch ended without firing"); + }); + + it("hands the observed outcome to the narration, not just the resolution", async () => { + const { store } = fakeStore(); + const { model, prompts } = recordingModel("Run run_abc123 failed after 4.2s."); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_failed", + clientData: CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, model); + }, + }); + + await harness.sendAction({ + ...WAKE, + identity: "run_finished:run_abc123", + spec: { ...WAKE.spec, kind: "run_finished", runId: "run_abc123" }, + resolution: "condition_met" as const, + observed: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 4200, + }, + facts: { outcome: "COMPLETED_WITH_ERRORS", durationMs: 4200 }, + }); + + const prompt = wakeText(prompts); + expect(prompt).toContain("What the final check observed"); + expect(prompt).toContain("COMPLETED_WITH_ERRORS"); + }); + + // A wake from a watcher that predates the resolution model still narrates: the + // resolution is reconstructed from the transport rather than lost. + it("falls back to the transport encoding when a wake carries no resolution", async () => { + const { store } = fakeStore(); + const { model, prompts } = recordingModel("That can't happen any more."); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_legacy", + clientData: CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, model); + }, + }); + + await harness.sendAction({ + ...WAKE, + type: "watch.expired" as const, + id: "watch:watch_1:expired", + facts: { reason: "terminal_unsatisfied" }, + }); + + expect(wakeText(prompts)).toContain("condition_impossible"); + }); + + // ------------------------------------------------------------------------- + // Watch → Investigate: the one relaxation of "never a new investigation + // unprompted" (§6). Consent is given at creation and applies to the ATTENTION + // outcomes only — the contracts mapping decides which those are. + // ------------------------------------------------------------------------- + + // A wake needs the project's external ref to scope the investigation exactly + // as a turn would; the watcher puts it in the wake's metadata. + const WAKE_CLIENT_DATA = { + ...CLIENT_DATA, + projectRef: "proj_abc", + environmentId: "env_abc", + }; + + const FAILED_RUN_WAKE = { + ...WAKE, + identity: "run_finished:run_abc123", + spec: { ...WAKE.spec, kind: "run_finished", runId: "run_abc123" }, + resolution: "condition_met" as const, + observed: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 4200, + }, + facts: { outcome: "COMPLETED_WITH_ERRORS", durationMs: 4200 }, + }; + + it("opens the pre-approved investigation on an attention outcome, in the same wake turn", async () => { + const { store, calls } = fakeStore(); + const { model, prompts } = recordingModel( + "Run run_abc123 failed — I've started looking into why." + ); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_investigate", + clientData: WAKE_CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, model); + }, + }); + + await harness.sendAction({ ...FAILED_RUN_WAKE, investigateOnAttention: true }); + + // The wake still lands first and says the investigation has started. + expect(calls.persistMessages).toHaveLength(1); + expect(wakeText(prompts)).toContain("ALREADY been started"); + + // And the investigation exists: opened, not concluded — the wake has no + // token to read with, so the findings come later in their own message. + expect(calls.upsertInvestigationRevision).toHaveLength(1); + const opened = calls.upsertInvestigationRevision[0] as { + chatId: string; + projectRef: string; + environmentRef: string; + state: { outcome: string; runId?: string }; + }; + expect(opened.chatId).toBe("chat_wake_investigate"); + expect(opened.projectRef).toBe("proj_abc"); + expect(opened.environmentRef).toBe("env_abc"); + expect(opened.state.outcome).toBe("in_progress"); + expect(opened.state.runId).toBe("run_abc123"); + }); + + // Consent is for bad news. A drained queue is the good kind, so the same flag + // starts nothing — the category comes from the contracts mapping, never from + // the flag or the resolution alone. + it("starts nothing on a positive outcome, consent or not", async () => { + const { store, calls } = fakeStore(); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_positive", + clientData: WAKE_CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, mockModel([textStep("The backlog drained.")])); + }, + }); + + await harness.sendAction({ + ...WAKE, + resolution: "condition_met" as const, + observed: { kind: "backlog_drain", verified: true, depth: 0 }, + investigateOnAttention: true, + }); + + expect(calls.persistMessages).toHaveLength(1); + expect(calls.upsertInvestigationRevision).toHaveLength(0); + }); + + it("starts nothing on an attention outcome without consent", async () => { + const { store, calls } = fakeStore(); + const { model, prompts } = recordingModel("Run run_abc123 failed after 4.2s."); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_no_consent", + clientData: WAKE_CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, model); + }, + }); + + await harness.sendAction(FAILED_RUN_WAKE); + + expect(calls.persistMessages).toHaveLength(1); + expect(calls.upsertInvestigationRevision).toHaveLength(0); + // …and the wake is never framed as having started one. + expect(wakeText(prompts)).not.toContain("ALREADY been started"); + }); + + // Binding independence (§6): scheduling the investigation never delays, + // retries or invalidates the wake. The watcher has already marked the delivery + // by the time the agent runs, so the only thing this can break is the turn — + // and it must not. + it("delivers the wake even when opening the investigation fails", async () => { + const { store, calls } = fakeStore(); + const failing: DashboardAgentStore = { + ...store, + upsertInvestigationRevision: async () => { + throw new Error("investigations are down"); + }, + }; + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_inv_fails", + clientData: WAKE_CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, failing); + set(dashboardAgentModelKey, mockModel([textStep("Run run_abc123 failed.")])); + }, + }); + + const wake = await harness.sendAction({ ...FAILED_RUN_WAKE, investigateOnAttention: true }); + + expect(collectText(wake.chunks)).toBe("Run run_abc123 failed."); + expect(calls.persistMessages).toHaveLength(1); + }); + + it("a different outcome on the same watch is a different wake", async () => { + const { store, calls } = fakeStore(); + harness = mockChatAgent(dashboardAgent, { + chatId: "chat_wake_two", + clientData: CLIENT_DATA, + setupLocals: ({ set }) => { + set(dashboardAgentStoreKey, store); + set(dashboardAgentModelKey, mockModel([textStep("first"), textStep("second")])); + }, + }); + + await harness.sendAction(WAKE); + await harness.sendAction({ + ...WAKE, + type: "watch.expired", + id: "watch:watch_2:expired", + watchId: "watch_2", + facts: { verified: false, reason: "unverified_at_expiry" }, + }); + + expect(calls.persistMessages).toHaveLength(2); + const latest = (calls.persistMessages[1] as { messages: UIMessage[] }).messages; + expect(latest.map((m) => m.id)).toEqual([ + "wake:watch:watch_1:fired", + "wake:watch:watch_2:expired", + ]); + }); +}); + // --------------------------------------------------------------------------- // Replayed tool-input sanitizing // --------------------------------------------------------------------------- @@ -548,6 +888,8 @@ describe("buildDashboardAgentTools", () => { [ "ask_support", "correlate_version", + "create_alert", + "delete_alert", "get_current_page", "get_deploy", "get_error", @@ -556,6 +898,7 @@ describe("buildDashboardAgentTools", () => { "get_report", "get_run", "get_run_trace", + "list_alerts", "list_deploys", "list_environments", "list_errors", @@ -565,6 +908,7 @@ describe("buildDashboardAgentTools", () => { "navigate_to", "run_query", "render_view", + "schedule_watch", "search_docs", ].sort() ); @@ -974,7 +1318,11 @@ describe("buildDashboardAgentTools", () => { const grounded = await renderInvestigation(tools, concludedWithSource); const actions = grounded.blocks[0].capabilities.actions; - expect(actions.map((a: { kind: string }) => a.kind)).toEqual(["show_code", "view_similar"]); + expect(actions.map((a: { kind: string }) => a.kind)).toEqual([ + "show_code", + "watch_recurrence", + "view_similar", + ]); expect(actions[0].intent.kind).toBe("ask"); // The ask is a propose-a-change request, not another explanation: a fenced // diff, the minimal change, anchored path:line@sha, with the dirty caveat. @@ -984,13 +1332,41 @@ describe("buildDashboardAgentTools", () => { expect(prompt).toMatch(/minimal change/i); expect(prompt).toMatch(/dirty tree|branch head/i); expect(prompt).toMatch(/don't restate the investigation/i); - // The follow-up that navigates points at the canonical error URI. + + // "Watch for a repeat" is a HANDOFF, not a question: it carries the kind and + // the subject, so the Watch card can be pre-filled without another LLM turn. expect(actions[1].intent).toEqual({ + kind: "watch", + spec: { + kind: "error_recurrence", + fingerprint: "error_c4b4a797397a9c43", + checkEveryMinutes: 15, + maxHours: 24, + note: `A repeat of: ${concludedWithSource.title}`, + }, + }); + + // The follow-up that navigates points at the canonical error URI. + expect(actions[2].intent).toEqual({ kind: "navigate", target: "trigger://proj_abc/env_abc/error/c4b4a797397a9c43", }); }); + // The handoff needs a subject a recurrence watch can be built on. A concluded + // card that cites no error group has none, so the action is left off rather + // than offering a button that can't pre-fill anything. + it("offers no repeat watch when the card cites no error group", async () => { + const { capability } = fakeInvestigations(); + const tools = buildDashboardAgentTools({ ...SCOPE, investigations: capability }); + + const output = await renderInvestigation(tools, concludedState); + const kinds = (output.blocks[0].capabilities?.actions ?? []).map( + (a: { kind: string }) => a.kind + ); + expect(kinds).not.toContain("watch_recurrence"); + }); + it("offers no actions while an investigation is still in progress", async () => { const { capability } = fakeInvestigations(); const tools = buildDashboardAgentTools({ ...SCOPE, investigations: capability }); @@ -998,7 +1374,9 @@ describe("buildDashboardAgentTools", () => { expect(output.blocks[0].capabilities).toBeUndefined(); }); - it("offers a keep-digging follow-up, and never Show code, on an inconclusive card", async () => { + // An inconclusive card has no cause to watch for a repeat OF, so the handoff + // stays off it — "keep digging" is the follow-up that fits. + it("offers a keep-digging follow-up, and never Show code or a repeat watch, on an inconclusive card", async () => { await seedWorkspace(); const { capability } = fakeInvestigations(); const tools = buildDashboardAgentTools({ @@ -1186,6 +1564,179 @@ describe("buildDashboardAgentTools", () => { await expect(renderView.execute({ blocks: [chart] }, {})).resolves.toEqual({ blocks: [chart] }); }); + // ------------------------------------------------------------------------- + // schedule_watch: the one tool that schedules future work + // ------------------------------------------------------------------------- + + const WATCH_CTX = { + userActorToken: "uat_token", + apiOrigin: "http://localhost:3030", + chatId: "chat_1", + }; + + const RUN_WATCH = { + kind: "run_finished" as const, + runId: "run_a1", + checkEveryMinutes: 1 as const, + maxHours: 2, + note: "tell me when the receipt run finishes", + }; + + // Runs schedule_watch against a stubbed global fetch and hands back both the + // tool's result and the request the host would have received. + async function scheduleWatch( + response: { status?: number; body: unknown }, + input: unknown = { watch: RUN_WATCH }, + ctx: Record = WATCH_CTX + ) { + const requests: Array<{ url: string; init: RequestInit | undefined }> = []; + const original = globalThis.fetch; + globalThis.fetch = (async (url: Parameters[0], init?: RequestInit) => { + requests.push({ url: String(url), init }); + return new Response(JSON.stringify(response.body), { + status: response.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + try { + const tools = buildDashboardAgentTools(ctx); + const scheduleTool = tools.schedule_watch as { + inputSchema: { parse: (input: unknown) => unknown }; + execute: (input: unknown, opts: unknown) => Promise; + }; + const result = await scheduleTool.execute(scheduleTool.inputSchema.parse(input), {}); + return { result, requests }; + } finally { + globalThis.fetch = original; + } + } + + it("schedule_watch posts the spec and the chat id as the user, and reports the created watch", async () => { + const { result, requests } = await scheduleWatch({ + body: { + watchId: "watch_1", + identity: "run_finished:run_a1", + status: "active", + expiresAt: "2026-01-01T14:00:00.000Z", + emailAlerts: "none", + }, + }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("http://localhost:3030/api/v1/dashboard-agent/watches"); + expect(requests[0]?.init?.method).toBe("POST"); + // The delegated user token — a watch is created with exactly the access of + // the user who asked for it. + expect((requests[0]?.init?.headers as Record | undefined)?.Authorization).toBe( + "Bearer uat_token" + ); + expect(JSON.parse(String(requests[0]?.init?.body))).toEqual({ + spec: RUN_WATCH, + chatId: "chat_1", + }); + + expect(result).toMatchObject({ + watchId: "watch_1", + identity: "run_finished:run_a1", + status: "active", + expiresAt: "2026-01-01T14:00:00.000Z", + checkEveryMinutes: 1, + watching: true, + // What the model needs to decide whether to offer an email alert. + emailAlerts: "none", + }); + }); + + it("schedule_watch passes the alert state through, and defaults to none when the host omits it", async () => { + for (const state of ["subscribed", "unavailable"] as const) { + const { result } = await scheduleWatch({ + body: { watchId: "watch_1", status: "active", emailAlerts: state }, + }); + expect(result.emailAlerts).toBe(state); + } + + // An older host that doesn't send the field must not read as "already + // subscribed" — the offer is the safe default. + const legacy = await scheduleWatch({ body: { watchId: "watch_1", status: "active" } }); + expect(legacy.result.emailAlerts).toBe("none"); + }); + + it("schedule_watch surfaces the limit and the duplicate as friendly text, naming the existing watch", async () => { + const limit = await scheduleWatch({ + status: 400, + body: { error: "too many", code: "limit_reached" }, + }); + expect(limit.result.error).toContain("3 active watches"); + + const duplicate = await scheduleWatch({ + status: 409, + body: { error: "already watching", code: "duplicate", existingId: "watch_existing" }, + }); + expect(duplicate.result.error).toContain("watch_existing"); + expect(duplicate.result.error).toContain("already being watched"); + }); + + // §2.2/§4.1: the host answered the request outright and created no watch, so + // the tool result is a ONE-SHOT the model must answer from in this turn. + it("schedule_watch reports a one-shot outcome instead of a running watch", async () => { + const { result } = await scheduleWatch({ + body: { + watching: false, + identity: "run_finished:run_abc", + immediate: { result: "satisfied", facts: { status: "COMPLETED" } }, + }, + }); + expect(result.watching).toBe(false); + expect(result.outcome).toBe("already_true"); + expect(result.immediate).toEqual({ result: "satisfied", facts: { status: "COMPLETED" } }); + // No watch id: there is nothing to cancel, and nothing will wake later. + expect(result.watchId).toBeUndefined(); + + const impossible = await scheduleWatch({ + body: { + watching: false, + identity: "run_finished:run_abc", + immediate: { result: "terminal_unsatisfied", facts: {} }, + }, + }); + expect(impossible.result.outcome).toBe("no_longer_possible"); + }); + + it("schedule_watch says so when the first check couldn't run", async () => { + const { result } = await scheduleWatch({ + body: { watching: true, watchId: "watch_1", status: "active", unavailable: true }, + }); + expect(result.watching).toBe(true); + expect(result.firstCheck).toBe("unavailable"); + expect(result.checked).toBe(false); + }); + + it("schedule_watch fails closed with no chat and rejects a cadence the contract floors", async () => { + const noChat = await scheduleWatch({ body: {} }, { watch: RUN_WATCH }, { + userActorToken: "uat_token", + apiOrigin: "http://localhost:3030", + } as Record); + expect(typeof noChat.result.error).toBe("string"); + expect(noChat.requests).toHaveLength(0); + + // Aggregate conditions are floored at 5 minutes by the contract's schema, so + // an over-eager watch never reaches the host. + await expect( + scheduleWatch( + { body: {} }, + { + watch: { + kind: "backlog_drain", + queue: "task/x", + checkEveryMinutes: 1, + maxHours: 1, + note: "n", + }, + } + ) + ).rejects.toThrow(); + }); + // The env-JWT exchange is a webapp request plus DB work, so it is paid for once // per tool set (= once per turn) no matter how many env-scoped tools run. const ENV_CTX = { @@ -1365,3 +1916,149 @@ describe("buildDashboardAgentTools", () => { expect(second.page).toEqual({ kind: "run", runId: "run_2" }); }); }); + +// --------------------------------------------------------------------------- +// Watch alert tools — project-level subscriptions, as the user +// --------------------------------------------------------------------------- + +describe("watch alert tools", () => { + const ALERT_CTX = { + userActorToken: "uat_token", + apiOrigin: "http://localhost:3030", + projectRef: "proj_abc", + environmentName: "prod", + chatId: "chat_alerts", + }; + + // Runs one alert tool against a stubbed global fetch, handing back the tool's + // result and the request the webapp would have received. + async function callAlertTool( + name: string, + input: unknown, + response: { status?: number; body: unknown }, + ctx: Record = ALERT_CTX + ) { + const requests: Array<{ url: string; init: RequestInit | undefined }> = []; + const original = globalThis.fetch; + globalThis.fetch = (async (url: Parameters[0], init?: RequestInit) => { + requests.push({ url: String(url), init }); + return new Response(JSON.stringify(response.body), { + status: response.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + try { + const tools = buildDashboardAgentTools(ctx); + const tool = tools[name] as { + inputSchema: { parse: (input: unknown) => unknown }; + execute: (input: unknown, opts: unknown) => Promise; + }; + const result = await tool.execute(tool.inputSchema.parse(input), {}); + return { result, requests }; + } finally { + globalThis.fetch = original; + } + } + + it("list_alerts reads the project's subscriptions as the user", async () => { + const alerts = [{ id: "alert_1", type: "EMAIL", label: "k***@trigger.dev", enabled: true }]; + const { result, requests } = await callAlertTool("list_alerts", {}, { body: { alerts } }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + "http://localhost:3030/api/v1/dashboard-agent/alerts?chatId=chat_alerts" + ); + expect(requests[0]?.init?.method).toBe("GET"); + expect((requests[0]?.init?.headers as Record | undefined)?.Authorization).toBe( + "Bearer uat_token" + ); + expect(result).toEqual({ alerts }); + }); + + it("create_alert posts the email channel and reports the created alert", async () => { + const { result, requests } = await callAlertTool( + "create_alert", + { email: "someone@example.com" }, + { body: { ok: true, alert: { id: "alert_2", type: "EMAIL" } } } + ); + + expect(requests[0]?.url).toBe("http://localhost:3030/api/v1/dashboard-agent/alerts"); + expect(requests[0]?.init?.method).toBe("POST"); + expect(JSON.parse(String(requests[0]?.init?.body))).toEqual({ + chatId: "chat_alerts", + channel: "email", + email: "someone@example.com", + }); + expect(result).toEqual({ created: true, alert: { id: "alert_2", type: "EMAIL" } }); + + // No email given: the host defaults to the user's account email, so the body + // carries only the chat scope and the channel. + const noEmail = await callAlertTool("create_alert", {}, { body: { ok: true } }); + expect(JSON.parse(String(noEmail.requests[0]?.init?.body))).toEqual({ + chatId: "chat_alerts", + channel: "email", + }); + }); + + it("create_alert relays a 403 with the reason the host gave", async () => { + const noEmailSetup = await callAlertTool( + "create_alert", + {}, + { status: 403, body: { error: "denied", reason: "email_alerts_not_configured" } } + ); + expect(noEmailSetup.result.error).toContain("isn't set up on this instance"); + expect(noEmailSetup.result.error).toContain("dashboard"); + + const flag = await callAlertTool( + "create_alert", + {}, + { status: 403, body: { error: "denied", reason: "dashboard_agent_disabled" } } + ); + expect(flag.result.error).toContain("aren't enabled here"); + }); + + it("create_alert relays the address refusal verbatim", async () => { + const refused = await callAlertTool( + "create_alert", + { email: "someone@else.com" }, + { + status: 400, + body: { + code: "email_not_allowed", + error: "Alerts can only be sent to your own account email.", + }, + } + ); + expect(refused.result.error).toBe("Alerts can only be sent to your own account email."); + }); + + it("delete_alert deletes by id and surfaces a failure as text", async () => { + const { result, requests } = await callAlertTool( + "delete_alert", + { alertId: "alert_1" }, + { body: { ok: true } } + ); + expect(requests[0]?.url).toBe("http://localhost:3030/api/v1/dashboard-agent/alerts/alert_1"); + expect(requests[0]?.init?.method).toBe("DELETE"); + expect(result).toEqual({ deleted: true, alertId: "alert_1" }); + + const missing = await callAlertTool( + "delete_alert", + { alertId: "alert_gone" }, + { status: 404, body: { error: "No such alert." } } + ); + expect(missing.result.error).toBe("No such alert."); + }); + + it("the alert tools fail closed with no delegated token, without hitting the network", async () => { + for (const [name, input] of [ + ["list_alerts", {}], + ["create_alert", {}], + ["delete_alert", { alertId: "alert_1" }], + ] as const) { + const { result, requests } = await callAlertTool(name, input, { body: {} }, {}); + expect(typeof result.error).toBe("string"); + expect(requests).toHaveLength(0); + } + }); +}); diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.ts b/internal-packages/dashboard-agent/src/dashboard-agent.ts index b17bbdc8063..9244de267fe 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.ts @@ -343,7 +343,13 @@ async function generateAndSaveTitle( import { agentPageContextSchema, investigationStateSchema, + isWatchKind, + resolveWatchResult, + watchResolutions, type InvestigationState, + type WatchObservedOutcome, + type WatchResolution, + type WatchSpec, } from "@internal/dashboard-agent-contracts"; export type { @@ -386,13 +392,93 @@ export const clientDataSchema = z.object({ .optional(), }); +/* ------------------------------------------------------------------ * + * Watch wakes + * ------------------------------------------------------------------ */ + +/** + * The wake, as the agent receives it. + * + * A watch fires (or expires) long after the turn that scheduled it, so there is + * no turn to answer on. The watcher task (`watch-tick.ts`) appends ONE record to + * this chat's `in` stream with `trigger: "action"`, which is the SDK's + * non-message input: it wakes (or re-triggers) the agent run and fires `onAction` + * only — no `onTurnStart`, no `run()`, no `onTurnComplete`, and the turn counter + * doesn't move. That's why the narration below does its own model call and its + * own persistence: the turn machinery isn't running. + * + * `id` is stable per (watch, outcome) — `watch:{watchId}:{fired|expired}` — and it + * becomes the narration message's id. That is the dedup: a redelivered wake (the + * watcher retried after appending but before marking the delivery) finds its + * message already in the history and narrates nothing. + * + * `type` and `id` keep that two-value encoding on purpose (§7.5, binding): it is + * the stable TRANSPORT, not the model. How the watch actually ended travels in + * `resolution`, and what was observed when it did travels in `observed`. + */ +export type WatchWakeAction = { + type: "watch.fired" | "watch.expired"; + /** `watch:{watchId}:{status}` — stable, so a redelivery is a no-op. */ + id: string; + watchId: string; + /** The watched thing, as the contracts' dedup string. */ + identity: string; + spec: WatchSpec & { since?: string }; + /** What the final check observed. The numbers the narration must use. */ + facts: Record; + /** How the watch ended: met · window completed · impossible. */ + resolution?: WatchResolution; + /** What was true when it ended — the run's final status, the depth, the count. */ + observed?: WatchObservedOutcome; + /** Why the watch exists, in the user's words. */ + note?: string; + /** + * The user consented at creation to an investigation after an ATTENTION + * outcome (§6). It relaxes exactly one rule — "never a new investigation + * unprompted" — and only for that outcome. + */ + investigateOnAttention?: boolean; +}; + +// Deliberately lenient on `spec`: a wake must never be lost to a validation +// error because the host persisted a spec field this version doesn't know about. +// The narration reads `kind`, `note` and the cadence; the rest passes through. +export const watchWakeActionSchema = z.object({ + type: z.enum(["watch.fired", "watch.expired"]), + id: z.string(), + watchId: z.string(), + identity: z.string().default(""), + spec: z + .object({ + kind: z.string(), + note: z.string().optional(), + checkEveryMinutes: z.number().optional(), + }) + .passthrough(), + facts: z.record(z.unknown()).default({}), + // Lenient for the same reason `spec` is: a wake must never be lost to a + // validation error. An older watcher that predates the resolution model simply + // sends neither, and the narration falls back to the transport encoding. + resolution: z.enum(watchResolutions).optional(), + observed: z.record(z.unknown()).optional(), + note: z.string().optional(), + investigateOnAttention: z.boolean().optional(), +}); + +// The wake's own line. How a wake is narrated (once, briefly, outcome + facts + +// one suggestion) lives in the managed system prompt's "Watches" section, which +// is the cached block — this is only the per-wake framing. +const WAKE_INSTRUCTION = + 'A watch you set up earlier has resolved and reports once, right now — this is not a question, and nobody is waiting on a reply. Write ONE short message: what the watch found, the numbers from the facts below, and one suggested next step. Say what happened; never say the watch "fired" or "expired". A window that ran out with the condition still not true is an answer, not a failure. No tools, no new investigation, no recap.'; + /** * Coerce replayed tool-call inputs the Anthropic API would reject back to `{}`. * * The model occasionally emits a no-arg tool call with a non-object input (empty * string, or `null` — which `typeof` also calls "object"), the SDK replays it * into history verbatim, and the API then fails the whole turn with - * "tool_use.input: Input should be an object". Used by `prepareMessages`. + * "tool_use.input: Input should be an object". Used by `prepareMessages` on + * normal turns and by the wake narration, which builds its model call directly. */ export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessage[] { const isBadInput = (part: unknown) => @@ -412,9 +498,258 @@ export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessa }) as ModelMessage[]; } +// Same Anthropic breakpoint `prepareMessages` rolls onto a turn's last message. +function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] { + if (messages.length === 0) return messages; + const last = messages[messages.length - 1]!; + return [ + ...messages.slice(0, -1), + { + ...last, + providerOptions: { + ...last.providerOptions, + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }, + ]; +} + +/** + * How the watch ended, in the resolution model's own words. + * + * The narration speaks resolution + observed outcome, never "fired"/"expired": + * those are the wire encoding (§7.5), and a watch that ran its whole window and + * found nothing has an ANSWER to give, not a failure to apologise for. + * + * Falls back to the transport when a wake predates the resolution model. + */ +function wakeResolution(action: WatchWakeAction): WatchResolution { + if (action.resolution) return action.resolution; + if (action.type === "watch.fired") return "condition_met"; + return (action.facts as { reason?: string } | undefined)?.reason === "terminal_unsatisfied" + ? "condition_impossible" + : "window_completed"; +} + +function wakeOutcome(action: WatchWakeAction): string { + switch (wakeResolution(action)) { + case "condition_met": + return "the condition became true inside the window"; + case "condition_impossible": + return "the condition can no longer become true — that is the answer, not a timeout"; + case "window_completed": + // Deliberately not "nothing happened": "it didn't drain in an hour" is + // exactly the thing the user asked to be told. + return "the window ran out with the condition still not true — this is the answer the user asked for, so report it plainly"; + } +} + +/** + * Whether THIS wake is the one the consent covers (§6, binding). + * + * Consent is for the ATTENTION outcomes only — the contracts' resolved-result + * mapping decides which those are, per kind, and no surface may substitute its + * own judgement. A drained queue and a quiet error group are good news and never + * start anything, however the watch was configured. + */ +export function wakeStartsInvestigation(action: WatchWakeAction): boolean { + if (action.investigateOnAttention !== true) return false; + const kind = action.spec.kind; + // A kind this build doesn't know has no mapping, so it has no category either. + if (!isWatchKind(kind)) return false; + const { category } = resolveWatchResult({ + kind, + resolution: wakeResolution(action), + outcome: action.observed as WatchObservedOutcome | undefined, + }); + return category === "attention"; +} + +/** The thing being watched, for the seeded investigation's own words. */ +function wakeSubject(action: WatchWakeAction): string { + const spec = action.spec as Record; + for (const key of ["runId", "queue", "fingerprint", "report"]) { + const value = spec[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return action.identity || String(spec.kind ?? "this"); +} + +// The wake's own line when the investigation is pre-approved. It states a fact +// about this turn — the investigation IS being opened here — so the model can't +// turn it into an offer, and the findings are explicitly somebody else's message. +function investigationInstruction(action: WatchWakeAction): string { + return `The user pre-approved an investigation for an outcome like this when they created the watch, and it has ALREADY been started for them — say so in one short clause, in the past tense, as part of your single message ("…I've started looking into why"). Never offer it, never ask, and don't describe what you'll check: the findings arrive later, in their own message with the investigation card. Subject: ${wakeSubject( + action + )}.`; +} + +function wakePrompt(action: WatchWakeAction): string { + return [ + WAKE_INSTRUCTION, + `Resolution: ${wakeResolution(action)} — ${wakeOutcome(action)}.`, + `Watching: ${action.spec.kind}${action.identity ? ` (${action.identity})` : ""}.`, + action.observed + ? `What the final check observed:\n${JSON.stringify(action.observed, null, 2)}` + : undefined, + action.note ? `Why the user asked for it: ${action.note}` : undefined, + `Facts from the check:\n${JSON.stringify(action.facts, null, 2)}`, + wakeStartsInvestigation(action) ? investigationInstruction(action) : undefined, + ] + .filter(Boolean) + .join("\n\n"); +} + +/** + * Open the pre-approved investigation — the ONE relaxation of "never a new + * investigation unprompted" (§6). + * + * It is deliberately a seeded `in_progress` state and nothing more: the wake + * turn has no delegated token to read with, so it opens the thread and the + * findings arrive later, in their own message with the card. + * + * Independence is the binding part (§6). This runs AFTER the narration is in the + * transcript, it never throws, and the watcher has already marked the wake + * delivered by the time the agent sees the action — so a failure here cannot + * delay, retry or invalidate the wake. + */ +async function openConsentedInvestigation(args: { + action: WatchWakeAction; + chatId: string; + clientData: z.infer | undefined; +}): Promise { + const { action, chatId, clientData } = args; + const projectRef = clientData?.projectRef; + const environmentRef = clientData?.environmentId; + if (!projectRef || !environmentRef) { + // A watch created before the row carried the project's external ref. Scoping + // it by the wrong identifier would strand the investigation, so skip it — + // the wake itself already landed. + logger.warn("dashboard-agent watch wake can't scope a consented investigation", { + chatId, + watchId: action.watchId, + }); + return; + } + + const subject = wakeSubject(action); + const spec = action.spec as { runId?: unknown }; + try { + const result = await getStore().upsertInvestigationRevision({ + chatId, + projectRef, + environmentRef, + state: { + outcome: "in_progress", + severity: "warn", + confidence: "low", + title: `Investigating ${subject}`, + headline: `The watch on ${subject} resolved to something that needs attention${ + action.note ? ` (${action.note})` : "" + }. Looking into why.`, + hypotheses: [], + evidence: [], + ...(typeof spec.runId === "string" ? { runId: spec.runId } : {}), + startedAt: new Date().toISOString(), + }, + }); + logger.info("dashboard-agent watch wake opened a consented investigation", { + chatId, + watchId: action.watchId, + investigationId: result.ok ? result.id : undefined, + }); + } catch (error) { + // The wake is the delivery that matters; an investigation that couldn't be + // opened is a lost follow-up, never a lost wake. + logger.error("dashboard-agent watch wake failed to open its investigation", { + chatId, + watchId: action.watchId, + error: (error as Error).message, + }); + } +} + +/** + * Narrate one wake, exactly once. + * + * Streams so the panel shows it arriving live, then writes it into both places a + * turn normally would: `chat.history` (the runtime transcript the model sees next + * turn) and the display read-model. Both happen before `onAction` returns — + * `chat.history` mutations are only picked up immediately after the hook. + */ +async function narrateWatchWake(args: { + action: WatchWakeAction; + chatId: string; + clientData: z.infer | undefined; + uiMessages: UIMessage[]; + /** The same history in model form, as the action event supplies it. */ + messages: ModelMessage[]; +}): Promise { + const { action, chatId, uiMessages } = args; + const messageId = `wake:${action.id}`; + + // Dedup on the action id. Durable, because the history it checks is the + // snapshot the SDK reseeds on every boot — not per-process state. + if (uiMessages.some((message) => message.id === messageId)) { + logger.info("dashboard-agent watch wake already narrated; skipping", { + chatId, + watchId: action.watchId, + actionId: action.id, + }); + return; + } + + const resolved = await getSystemPrompt(modeFor(args.clientData)); + const result = streamText({ + model: + locals.get(dashboardAgentModelKey) ?? + registry.languageModel( + (resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}` + ), + system: resolved.text, + // The conversation so far plus the wake. No tools: a wake reports what the + // check already established, and it carries no delegated token to read with. + // The breakpoint goes on the last message of the EXISTING prefix (not on the + // wake, which is unique and would only ever be a cache write), so the wake + // reads back the same cached prefix a normal turn would. + messages: [ + ...withCacheBreakpointOnLast(sanitizeReplayedToolInputs(args.messages)), + { role: "user" as const, content: wakePrompt(action) }, + ], + ...resolved.toAISDKTelemetry(), + }); + + // Pipe explicitly (rather than returning the result) so the final text is in + // hand for the history + read-model writes below. The streamed message must + // carry the SAME id the copy below is persisted under — the panel merges the + // live stream with the loaded history by message id, and two ids for one + // narration render it twice. + await chat.pipe(result.toUIMessageStream({ generateMessageId: () => messageId })); + const text = (await result.text).trim(); + if (!text) return; + + const message: UIMessage = { + id: messageId, + role: "assistant", + parts: [{ type: "text", text }], + }; + const messages = [...uiMessages, message]; + chat.history.set(messages); + await getStore().persistMessages({ chatId, messages }); + + // Only now, with the wake in the transcript: the investigation is the turn's + // business after the banner exists, and it can never hold the banner up. + if (wakeStartsInvestigation(action)) { + await openConsentedInvestigation({ action, chatId, clientData: args.clientData }); + } +} + export const dashboardAgent = chat.agent({ id: "dashboard-agent", clientDataSchema, + // The only action the agent accepts: a watch wake, appended by the watcher + // task. Actions are not turns — see `narrateWatchWake`. + actionSchema: watchWakeActionSchema, // Latency levers come next (Head Start, prompt caching, AI Prompts). Scaffold // keeps a short idle window so suspended runs release their DB pool. idleTimeoutInSeconds: 60, @@ -462,6 +797,19 @@ export const dashboardAgent = chat.agent({ }); }, + // A watch fired or expired. The narration is one message, deduped on the + // action id, and it returns void: the stream is piped inside so the final + // text can be written to the history and the read-model. + onAction: async ({ action, chatId, clientData, uiMessages, messages }) => { + await narrateWatchWake({ + action: action as WatchWakeAction, + chatId, + clientData, + uiMessages, + messages, + }); + }, + onTurnStart: async ({ chatId, uiMessages, clientData }) => { // Make the user's message durable in the display copy before the model // starts streaming. Awaited, never chat.defer — a mid-stream refresh must diff --git a/internal-packages/dashboard-agent/src/index.ts b/internal-packages/dashboard-agent/src/index.ts index b610d0fc04e..a91c215e64d 100644 --- a/internal-packages/dashboard-agent/src/index.ts +++ b/internal-packages/dashboard-agent/src/index.ts @@ -6,6 +6,12 @@ // register the task in the webapp's context. export * from "./dashboard-agent.js"; +// The watcher task, for the webapp: it triggers the first tick when it creates a +// watch. TYPE-ONLY on purpose — the webapp must trigger it by id +// (`tasks.trigger("dashboard-agent-watch", payload)`) and must +// never import the task value. +export type { WatchTickPayload, watchTick } from "./watch-tick.js"; + // The view-catalog block types, for the webapp's render registry. They now live // in `@internal/dashboard-agent-contracts` (a zod-only leaf) and are re-exported // here so existing `import type { ViewBlock } from "@internal/dashboard-agent"` diff --git a/internal-packages/dashboard-agent/src/tool-schemas.ts b/internal-packages/dashboard-agent/src/tool-schemas.ts index a636eecd430..5f7c1ca76a4 100644 --- a/internal-packages/dashboard-agent/src/tool-schemas.ts +++ b/internal-packages/dashboard-agent/src/tool-schemas.ts @@ -11,7 +11,11 @@ * the user) live in `tools.ts`, which imports these schemas and adds executes * on top; the route handler never sees them. */ -import { runFiltersSchema, viewBlockInputSchema } from "@internal/dashboard-agent-contracts"; +import { + runFiltersSchema, + viewBlockInputSchema, + watchSpecSchema, +} from "@internal/dashboard-agent-contracts"; import { tool } from "ai"; import { z } from "zod"; @@ -353,6 +357,68 @@ export const renderViewSchema = tool({ }), }); +// --------------------------------------------------------------------------- +// Watches — "tell me when X happens", instead of the user re-asking. +// +// The spec the model composes IS the frozen contract (`watchSpecSchema`): the +// cadence floors (run state may poll every minute, aggregates are floored at 5) +// and the 24h ceiling are enforced by the schema, so an over-eager watch fails +// validation instead of turning into a hot loop. `since` for error recurrence is +// server-set on persist and is deliberately absent here — the model can't +// backdate a recurrence window. +// --------------------------------------------------------------------------- + +export const scheduleWatchSchema = tool({ + description: + "Watch something and tell the user when it happens, later, without them asking again. Use this whenever they want to be told about a future event: a run starting or finishing, a queue draining or growing past a threshold, an error recurring, the health report recovering. This is the ONLY way to answer that — never poll by calling read tools over and over. The watch checks on its own cadence and reports ONCE, with what it found; it stops within 24 hours either way, and a window that ran out with the condition still not true is still an answer. A chat may hold at most 3 at once. `note` is why the watch exists in the user's own words — it is shown with the result. If the condition is already true (or can no longer become true) when you call this, no watch is created: you get the answer back immediately and must answer from it in the same turn.", + inputSchema: z.object({ + watch: watchSpecSchema.describe( + "What to watch, how often to check, and how long to keep watching. `note` is why the watch exists in the user's own words — it is shown when it fires." + ), + investigateOnAttention: z + .boolean() + .optional() + .describe( + "Set this ONLY when the user explicitly asked you to dig in / look into it / find out why if the outcome is bad — never as a helpful extra. It gives standing permission to open an investigation when (and only when) the watch resolves to something needing attention. Leave it out otherwise." + ), + }), +}); + +// --------------------------------------------------------------------------- +// Watch alerts — an email on top of the always-on dashboard notification. +// +// Project-level subscriptions to the watch alert type, so a wake reaches the +// user when they aren't looking at the dashboard. Creating one is a write the +// user has to ask for, and it can be denied by plan or feature flag (403). +// --------------------------------------------------------------------------- + +export const listAlertsSchema = tool({ + description: + 'List this project\'s alert subscriptions for watch results — who gets notified when a watch resolves, and whether each one is enabled. Use this to answer "what alerts do I have?".', + inputSchema: z.object({}), +}); + +export const createAlertSchema = tool({ + description: + "Subscribe to an email alert for every watch that resolves in this project. It always goes to the user's own account email. ONLY call this when the user explicitly asked for an alert — never as a helpful extra. If it comes back denied, relay that honestly and offer the dashboard notification, which is always on, instead.", + inputSchema: z.object({ + email: z + .string() + .optional() + .describe( + "Omit this. Alerts can only go to the user's own account email; any other address is rejected." + ), + }), +}); + +export const deleteAlertSchema = tool({ + description: + "Turn one alert subscription off, by its id from list_alerts. Watch results still show in the dashboard.", + inputSchema: z.object({ + alertId: z.string().describe("The alert id returned by list_alerts."), + }), +}); + // Code-mode tools (only present when the project has a connected GitHub repo). // They read the repo's source at a pinned commit from the agent's filesystem. @@ -442,6 +508,10 @@ export const dashboardAgentToolSchemas = { search_docs: searchDocsSchema, get_current_page: getCurrentPageSchema, navigate_to: navigateToSchema, + schedule_watch: scheduleWatchSchema, + list_alerts: listAlertsSchema, + create_alert: createAlertSchema, + delete_alert: deleteAlertSchema, }; // Code mode adds the source tools. Same key order `buildDashboardAgentTools` @@ -491,6 +561,10 @@ You have read-only tools that act as the user against their own account: - search_docs: search the Trigger.dev documentation. - get_current_page: the page the user is on right now, and what the dashboard already noticed on it. - navigate_to: take the user to a run, error, queue, deployment, or a filtered runs list. +- schedule_watch: watch for something to happen (a run finishing, a backlog draining, an error recurring, health recovering) and tell the user when it does. +- list_alerts: the project's alert subscriptions for watch fires. +- create_alert: subscribe the user to an email alert for watch fires in this project. +- delete_alert: turn one alert subscription off. Guidelines: - Be concise and direct. A short, correct answer beats a long one. Default to 2-4 sentences; go longer only when the user asked for detail or the answer genuinely needs it. @@ -526,6 +600,21 @@ Is anything wrong?: - When the report points at flow (runs not starting), follow up with get_queue on the queue it names to see depth, wait time, and throttling. When it points at execution, follow up with list_errors / get_run_trace. - When something started failing at a particular time, check list_deploys for a deploy in that window, and correlate_version on a failing run to see the exact commit and pull request it ran. +Watches — telling the user later: +- When the user wants to be told when something happens ("tell me when this run finishes", "let me know when the backlog drains", "ping me if that error comes back", "tell me when prod is healthy again"), call schedule_watch. Never poll: repeating a read tool until the thing happens is not a watch, and you cannot wait inside a turn. +- Confirm four things in one line: what is being watched, how often it checks, that it fires ONCE and is then done, and exactly when it gives up (the maxHours you set — e.g. "or stops in 6 hours if it doesn't happen"). A watch is never open-ended and the user must not have to ask. Pick the longest cadence that still answers in time — 1 minute only for a run's state, 5 minutes or more for backlog, error recurrence, and health. +- A chat holds at most 3 watches. If the tool says the limit is reached or that this thing is already watched, say so and name the existing watch instead of trying again. +- If the tool returns an immediate outcome, the condition already holds: answer now and don't promise a message later. +- A watch wake is a message you send unprompted, and it is narrated ONCE, briefly: what the outcome was, the numbers from the facts you were given, and one suggested next step. Nothing else — no new investigation, no fresh reads, no recap of the conversation. +- The ONE exception to "no new investigation": the user consented at creation ("watch it and dig in if it goes wrong"). Pass investigateOnAttention on schedule_watch only when they asked for that in so many words, and confirm it in the same line as the rest of the watch. Never add it as a helpful extra — an investigation nobody asked for is worse than none. +- A consented investigation applies only to outcomes that need attention: a run that failed, a queue that stayed backed up, an error that came back. Good news and neutral news end the watch and nothing else happens. When the wake tells you the investigation has already started, say so in one short clause and stop — the findings come later, in their own message. +- On an expiry, say which of the two happened: it didn't happen in the window, or the condition couldn't be verified at expiry (then give the last observation and don't claim either way). +- Only call a wait "queue wait" when the facts measured it from when the run was queued. If the facts only have time from creation to start, call it that. +- When schedule_watch returns emailAlerts "none", the confirmation line MAY end with one short offer: "I can also email you when it fires — say the word." On "subscribed" add nothing, they already get one; on "unavailable" say nothing at all — never advertise an alert the plan denies. +- After a wake that fired, and only if no alert is subscribed yet, your ONE suggested next step may be that same offer — one short line. Never create an alert unprompted. +- Call create_alert only after the user confirms. If it comes back denied (plan or feature flag), say so plainly and add that the dashboard still shows the notification badge for every fire. +- "What alerts do I have?" is list_alerts. Turning one off is delete_alert — if which one is ambiguous, list them and ask which. + Product questions: - For "how do I …" questions about Trigger.dev itself, use search_docs and answer from what it returns, citing the doc. ask_support is for longer, composed troubleshooting answers. Never invent an API or option that isn't in either. - When the answer sends the user to a specific URL — the contact page, the status page, a docs page — write it as a markdown link, never as bare text they have to retype. diff --git a/internal-packages/dashboard-agent/src/tools.ts b/internal-packages/dashboard-agent/src/tools.ts index ccc397a9365..b0ba66a9053 100644 --- a/internal-packages/dashboard-agent/src/tools.ts +++ b/internal-packages/dashboard-agent/src/tools.ts @@ -5,6 +5,7 @@ import { investigationBlockSchema, safeParseTriggerUri, VIEW_BLOCK_VERSION, + WATCH_MAX_HOURS, type AgentPageContext, type Evidence, type EvidenceRef, @@ -20,6 +21,8 @@ import { tool, type ToolSet } from "ai"; import { askSupportSchema, correlateVersionSchema, + createAlertSchema, + deleteAlertSchema, getCurrentPageSchema, getDeploySchema, getErrorSchema, @@ -28,6 +31,7 @@ import { getReportSchema, getRunSchema, getRunTraceSchema, + listAlertsSchema, listDeploysSchema, listEnvironmentsSchema, listErrorsSchema, @@ -37,6 +41,7 @@ import { navigateToSchema, renderViewSchema, runQuerySchema, + scheduleWatchSchema, searchDocsSchema, } from "./tool-schemas"; import { buildRepoTools, type RepoSnapshot } from "./repo-tools"; @@ -69,7 +74,8 @@ export type DashboardAgentToolContext = { // The dashboard path the user is on, passed as context to ask_support. currentPage?: string; // The chat this turn belongs to, supplied by dashboard-agent.ts (the same seam - // the investigations capability comes through). + // the investigations capability comes through). A watch belongs to a chat: it + // is guardrailed per chat and its wake is delivered back into this one. chatId?: string; // Structured view of the same page, when the host could classify it. Read by // get_current_page so the agent can resolve "this run" without asking. @@ -477,6 +483,14 @@ export function showCodeAskPrompt(args: { path: string; line: number; sha: strin ); } +/** + * The window and cadence the "Watch for a repeat" handoff proposes. Defaults the + * card shows and the user can change, not a decision: the longest window a watch + * may have (a recurrence question is "does this come back at all"), on the + * aggregate floor's next step up. + */ +const RECURRENCE_WATCH = { checkEveryMinutes: 15, maxHours: WATCH_MAX_HOURS } as const; + // Always returns the same tool set so it stays stable across turns (the SDK // replays it over prior history). When a turn carried no delegated token, each // tool reports that rather than silently disappearing. @@ -534,6 +548,57 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe return withEnvJwt((jwt) => apiGet(origin, path, jwt), unauthorizedGet); } + /** + * One request to the watch-alerts routes, as the user (delegated token). + * + * Failures come back as `{ error }` in the model's own terms — including the + * 403, whose `reason` says whether the plan or the feature flag denied it, so + * the model can relay that instead of inventing a cause. + */ + async function alertsRequest( + method: "GET" | "POST" | "DELETE", + path: string, + body?: unknown + ): Promise<{ data: unknown } | { error: string }> { + let res: Response; + try { + res = await fetch(`${origin}${path}`, { + method, + headers: { + Authorization: `Bearer ${userActorToken!}`, + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch (error) { + return { error: `Couldn't reach the alerts API: ${(error as Error).message}` }; + } + + const data = (await res.json().catch(() => undefined)) as + | { error?: string; reason?: string; code?: string } + | undefined; + + // 403 is a capability refusal, and the host says which one. A 400 with + // `email_not_allowed` is a caller mistake (an address that isn't the user's + // own), and its message is written to be relayed as-is. + if (res.status === 403) { + return { + error: + data?.reason === "email_alerts_not_configured" + ? "Email delivery isn't set up on this instance, so an email alert can't be created. Tell the user that, and that watch results still show in the dashboard." + : "Email alerts aren't enabled here. Tell the user that, and that watch results still show in the dashboard.", + }; + } + if (res.status === 400 && data?.code === "email_not_allowed") { + return { error: data.error ?? "Alerts can only go to the user's own account email." }; + } + if (!res.ok) { + return { error: data?.error ?? `The alerts API failed (status ${res.status}).` }; + } + return { data }; + } + // Run-SHA pinning: ask the webapp for a snapshot pinned to a specific run's // deployed commit (it mints the scoped token + signed URL server-side). null // means the file tools fall back to the default tracked-branch snapshot. @@ -877,6 +942,37 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe } const errorUri = cited.find((evidence) => evidence.kind === "error")?.uri; + + // "Watch for a repeat" is a HANDOFF, not a question: it hands the Watch card + // a ready subject (§6 of the Investigate spec). So it needs a subject a + // recurrence watch can actually be built on — a cited error fingerprint — + // and a cause worth watching for, which only a concluded card has. Without + // the fingerprint there is nothing to pre-fill and the action is left off. + const parsedError = errorUri ? safeParseTriggerUri(errorUri) : undefined; + if ( + state.outcome === "concluded" && + parsedError?.success && + parsedError.data.kind === "error" + ) { + actions.push({ + kind: "watch_recurrence", + label: "Watch for a repeat", + intent: { + kind: "watch", + // The spec IS the pre-fill: kind + subject, plus the defaults the card + // shows before the user customizes them. Nothing is created by + // emitting it — the host decides what to do with an intent. + spec: { + kind: "error_recurrence", + fingerprint: parsedError.data.fingerprint, + checkEveryMinutes: RECURRENCE_WATCH.checkEveryMinutes, + maxHours: RECURRENCE_WATCH.maxHours, + note: `A repeat of: ${state.title}`, + }, + }, + }); + } + if (errorUri) { actions.push({ kind: "view_similar", @@ -1477,6 +1573,175 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe } }, }), + + // The one tool that schedules future work. The host owns everything the + // model shouldn't: the tenancy snapshot, the watch token, the periodic + // checks, and the guardrails (≤3 per chat, no duplicate on the same thing, + // 24h ceiling). The model composes the spec and gets back one of two things: + // a running watch, or — when the creation-time check already answered the + // request — a ONE-SHOT result with no watch behind it at all. + schedule_watch: tool({ + ...scheduleWatchSchema, + execute: async ({ watch, investigateOnAttention }) => { + if (!hasAuth) return NO_AUTH; + if (!ctx.chatId) { + return { error: "This turn isn't attached to a chat, so a watch can't be scheduled." }; + } + let res: Response; + try { + res = await fetch(`${origin}/api/v1/dashboard-agent/watches`, { + method: "POST", + headers: { + Authorization: `Bearer ${userActorToken!}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + spec: watch, + chatId: ctx.chatId, + // Only ever sent as `true`: the flag is consent, and an absent + // field is the same as a refused one. + ...(investigateOnAttention === true ? { investigateOnAttention: true } : {}), + }), + }); + } catch (error) { + return { error: `Couldn't schedule the watch: ${(error as Error).message}` }; + } + + const data = (await res.json().catch(() => undefined)) as + | { + /** False = the ONE-SHOT result: the check answered, no watch exists. */ + watching?: boolean; + watchId?: string; + identity?: string; + status?: string; + expiresAt?: string; + /** The creation-time check couldn't run; the watch is active anyway. */ + unavailable?: boolean; + /** Whether a resolution would already reach the user outside the chat. */ + emailAlerts?: "subscribed" | "none" | "unavailable"; + immediate?: { result?: string; facts?: unknown }; + existingId?: string; + error?: string; + code?: string; + } + | undefined; + + if (!res.ok) { + switch (data?.code) { + case "limit_reached": + return { + error: + "This chat already has the maximum of 3 active watches. Cancel one of them before adding another.", + }; + case "duplicate": + return { + error: `That's already being watched in this chat${ + (data.existingId ?? data.watchId) + ? ` (watch ${data.existingId ?? data.watchId})` + : "" + }, so nothing new was scheduled. Tell the user the existing watch covers it.`, + }; + case "invalid_target": + return { + error: + data.error ?? + "That thing can't be watched — check the run id, queue, or error group is right.", + }; + default: + return { + error: data?.error ?? `Couldn't schedule the watch (status ${res.status}).`, + }; + } + } + + // The ONE-SHOT result. The creation-time check already answered the + // request, so NO watch was created — no row, no chip, no later wake. The + // model must answer from this result now; there is nothing to promise. + if (data?.watching === false && data.immediate) { + const satisfied = data.immediate.result === "satisfied"; + return { + watching: false, + identity: data.identity, + outcome: satisfied ? "already_true" : "no_longer_possible", + immediate: data.immediate, + note: satisfied + ? "That already happened, so there is nothing left to watch. Answer now from these facts." + : "That can no longer happen, so there is nothing to watch. Answer now from these facts.", + }; + } + + return { + watchId: data?.watchId, + identity: data?.identity, + status: data?.status ?? "active", + expiresAt: data?.expiresAt, + checkEveryMinutes: watch.checkEveryMinutes, + note: watch.note, + watching: true, + // Echoed so the confirmation can say the second half out loud: this + // watch will also start looking into it if the news is bad. + ...(investigateOnAttention === true ? { investigateOnAttention: true } : {}), + // The first check couldn't run. The watch is running anyway; say so + // rather than implying the condition was evaluated. + ...(data?.unavailable + ? { firstCheck: "unavailable" as const, checked: false } + : { checked: true }), + // Decides whether the confirmation may offer an email alert: only + // "none" leaves something to offer. + emailAlerts: data?.emailAlerts ?? "none", + }; + }, + }), + + // Watch alerts. Project-level subscriptions, so they authenticate as the user + // with the delegated token (the same lane as watch creation) — not the env + // JWT. Every call carries the chat id: the API scopes its authorization + // through the chat, exactly like watch creation does. + list_alerts: tool({ + ...listAlertsSchema, + execute: async () => { + if (!hasAuth) return NO_AUTH; + if (!ctx.chatId) return { error: "No chat is available to read alerts from." }; + const result = await alertsRequest( + "GET", + `/api/v1/dashboard-agent/alerts?chatId=${encodeURIComponent(ctx.chatId)}` + ); + if ("error" in result) return result; + const alerts = (result.data as { alerts?: unknown } | undefined)?.alerts; + return { alerts: Array.isArray(alerts) ? alerts : [] }; + }, + }), + + create_alert: tool({ + ...createAlertSchema, + execute: async ({ email }) => { + if (!hasAuth) return NO_AUTH; + if (!ctx.chatId) return { error: "No chat is available to create an alert from." }; + const result = await alertsRequest("POST", "/api/v1/dashboard-agent/alerts", { + chatId: ctx.chatId, + channel: "email", + ...(email ? { email } : {}), + }); + if ("error" in result) return result; + return { created: true, alert: (result.data as { alert?: unknown } | undefined)?.alert }; + }, + }), + + delete_alert: tool({ + ...deleteAlertSchema, + execute: async ({ alertId }) => { + if (!hasAuth) return NO_AUTH; + if (!ctx.chatId) return { error: "No chat is available to change alerts from." }; + const result = await alertsRequest( + "DELETE", + `/api/v1/dashboard-agent/alerts/${encodeURIComponent(alertId)}`, + { chatId: ctx.chatId } + ); + if ("error" in result) return result; + return { deleted: true, alertId }; + }, + }), }; // Code mode: when the project has a connected repo, add the source tools. diff --git a/internal-packages/dashboard-agent/src/watch-tick.test.ts b/internal-packages/dashboard-agent/src/watch-tick.test.ts new file mode 100644 index 00000000000..2e3f60729c9 --- /dev/null +++ b/internal-packages/dashboard-agent/src/watch-tick.test.ts @@ -0,0 +1,971 @@ +import { watchResolutionToWireStatus } from "@internal/dashboard-agent-contracts"; +import type { Watch } from "@internal/dashboard-agent-db"; +import { describe, expect, it } from "vitest"; + +import { + runWatchTick, + type WatchTickDeps, + type WatchTickPayload, + type WatchTickStore, +} from "./watch-tick"; +import type { WatchWakeAction } from "./dashboard-agent"; + +/** + * The tick's lifecycle, driven through the `deps` seam: a fake store over an + * in-memory row (with the real queries' guards — every transition is conditional + * on the row's current state), a fake fetch, and a fake session append. No mocks + * and no database: what's under test is the ordering, and the ordering is where + * a wake gets lost or sent twice. + */ + +/** The payload for the generation an invocation owns. */ +function payloadFor(tick: number): WatchTickPayload { + return { + watchId: "watch_1", + token: "watch_token", + apiOrigin: "http://localhost:3030", + tick, + }; +} + +/** The first generation, which the webapp schedules when it creates the watch. */ +const PAYLOAD = payloadFor(1); + +const NOW = new Date("2026-01-01T12:00:00.000Z"); + +function watchRow(overrides: Partial = {}): Watch { + return { + id: "watch_1", + chatId: "chat_1", + identity: "run_finished:run_a1", + spec: { + kind: "run_finished", + runId: "run_a1", + checkEveryMinutes: 1, + maxHours: 2, + note: "tell me when the receipt run finishes", + }, + status: "active", + deliveryStatus: "not_required", + cancelReason: null, + investigateOnAttention: false, + organizationId: "org_1", + projectId: "proj_1", + projectRef: "proj_abc", + environmentId: "env_1", + userId: "user_1", + createdAt: new Date("2026-01-01T11:00:00.000Z"), + expiresAt: new Date("2026-01-01T13:00:00.000Z"), + lastCheckedAt: null, + firedAt: null, + deliveryClaimedAt: null, + deliveryClaimId: null, + deliveredAt: null, + cancelledAt: null, + lastResult: null, + tickCount: 0, + ...overrides, + } as Watch; +} + +// A store over one row, guarded exactly like the real queries: the generation +// claim only lands on an `active` row still on the previous generation, the +// condition transition only applies to an `active` row, and the release / delivered +// marks only to the claim whose fencing token the row still carries. +function fakeStore(row: Watch) { + let claimSeq = 0; + const calls = { + claims: [] as unknown[], + transition: [] as unknown[], + deliveryClaims: [] as unknown[], + released: [] as unknown[], + delivered: [] as unknown[], + checks: [] as unknown[], + }; + const store: WatchTickStore = { + getWatch: async () => ({ ...row }), + claimWatchTick: async (params) => { + calls.claims.push(params); + if (row.status !== "active") return null; + // Resumable, exactly like the real query: the previous generation (fresh) or + // this one (a retry resuming its own generation), never one further ahead. + if (row.tickCount !== params.generation - 1 && row.tickCount !== params.generation) { + return null; + } + row.tickCount = params.generation; + // Deliberately NOT lastCheckedAt: a claim is not an observation. + return { ...row }; + }, + claimWatchDelivery: async (params) => { + calls.deliveryClaims.push(params); + const stale = + row.deliveryStatus === "delivering" && + (row.deliveryClaimedAt ?? row.createdAt).getTime() <= params.staleBefore.getTime(); + if (row.deliveryStatus !== "pending" && !stale) return null; + const claimId = `wdc_${++claimSeq}`; + row.deliveryStatus = "delivering"; + row.deliveryClaimedAt = NOW; + row.deliveryClaimId = claimId; + return { watch: { ...row }, claimId }; + }, + releaseWatchDelivery: async (params) => { + calls.released.push(params); + // Fenced: only the deliverer whose token the row still holds may release it. + if (row.deliveryStatus !== "delivering" || row.deliveryClaimId !== params.claimId) + return null; + row.deliveryStatus = "pending"; + row.deliveryClaimedAt = null; + row.deliveryClaimId = null; + return { ...row }; + }, + transitionWatchCondition: async (params) => { + calls.transition.push(params); + if (row.status !== "active") return null; + // Mirrors the query layer: the two-value status is DERIVED from the + // resolution (§7.5), never passed in alongside it. + const status = watchResolutionToWireStatus(params.resolution); + row.status = status; + row.resolution = params.resolution; + row.deliveryStatus = "pending"; + row.lastCheckedAt = NOW; + if (status === "fired") row.firedAt = NOW; + if (params.observedOutcome !== undefined) row.observedOutcome = params.observedOutcome; + if (params.lastResult !== undefined) row.lastResult = params.lastResult; + return { ...row }; + }, + markWatchDelivered: async (params) => { + calls.delivered.push(params); + // Same fence: a mark from a taken-over deliverer completes nothing. + if (row.deliveryStatus !== "delivering" || row.deliveryClaimId !== params.claimId) + return null; + row.deliveryStatus = "delivered"; + row.deliveredAt = NOW; + return { ...row }; + }, + recordWatchCheck: async (params) => { + calls.checks.push(params); + if (row.status !== "active") return null; + row.lastCheckedAt = NOW; + if (params.lastResult !== undefined) row.lastResult = params.lastResult; + return { tickCount: row.tickCount, lastCheckedAt: row.lastCheckedAt }; + }, + }; + return { store, calls, row }; +} + +type FetchCall = { url: string; init: RequestInit | undefined }; + +function fakeFetch(responder: (call: FetchCall) => { status?: number; body: unknown }): { + fetch: typeof fetch; + calls: FetchCall[]; +} { + const calls: FetchCall[] = []; + const fetchImpl = (async (input: Parameters[0], init?: RequestInit) => { + const call = { url: String(input), init }; + calls.push(call); + const { status = 200, body } = responder(call); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return { fetch: fetchImpl, calls }; +} + +function fakeDeliver(options: { throwOnce?: boolean } = {}) { + const appends: Array<{ chatId: string; action: WatchWakeAction }> = []; + let thrown = false; + return { + appends, + deliver: async ({ chatId, action }: { chatId: string; action: WatchWakeAction }) => { + if (options.throwOnce && !thrown) { + thrown = true; + throw new Error("session append failed"); + } + appends.push({ chatId, action }); + }, + }; +} + +function fakeNotifyFired(options: { throws?: boolean } = {}) { + const notified: string[] = []; + return { + notified, + notifyFired: async (watchId: string) => { + notified.push(watchId); + if (options.throws) throw new Error("the fired callback returned 500"); + }, + }; +} + +function fakeReschedule() { + const triggers: Array<{ payload: WatchTickPayload; options: Record }> = []; + return { + triggers, + reschedule: async (payload: WatchTickPayload, options: Record) => { + triggers.push({ payload, options }); + }, + }; +} + +// Assemble the seams into one deps object. +function deps(parts: { + store: WatchTickStore; + fetch: typeof fetch; + deliver: WatchTickDeps["deliver"]; + reschedule: WatchTickDeps["reschedule"]; + notifyFired?: WatchTickDeps["notifyFired"]; + now?: Date; +}): WatchTickDeps { + return { + store: parts.store, + fetch: parts.fetch, + deliver: parts.deliver, + reschedule: parts.reschedule, + notifyFired: parts.notifyFired ?? (async () => {}), + now: () => parts.now ?? NOW, + }; +} + +describe("runWatchTick", () => { + it("pending: claims its generation, records the check, and reschedules the next generation", async () => { + const { store, calls, row } = fakeStore(watchRow({ tickCount: 3 })); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ body: { result: "pending" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick(payloadFor(4), deps({ store, fetch, deliver, reschedule })); + + expect(result).toEqual({ outcome: "pending", tickCount: 4 }); + // The counter moved exactly once, in the claim. + expect(calls.claims).toEqual([{ id: "watch_1", generation: 4 }]); + expect(row.tickCount).toBe(4); + // The check went to the watch's own endpoint with the watch token, and was + // NOT flagged final (the watch has an hour left). + expect(fetchCalls[0]?.url).toBe( + "http://localhost:3030/api/v1/dashboard-agent/watches/watch_1/check" + ); + expect( + (fetchCalls[0]?.init?.headers as Record | undefined)?.Authorization + ).toBe("Bearer watch_token"); + expect(JSON.parse(String(fetchCalls[0]?.init?.body))).toEqual({}); + + // The check result is recorded without touching the counter, and the successor + // carries the next generation in both the payload and the key. + expect(calls.checks).toEqual([{ id: "watch_1", lastResult: {} }]); + expect(triggers).toEqual([ + { + payload: payloadFor(5), + options: { delay: "1m", idempotencyKey: "watch:watch_1:tick:5" }, + }, + ]); + + // Nothing terminal, nothing delivered. + expect(row.status).toBe("active"); + expect(appends).toHaveLength(0); + }); + + it("satisfied: transitions to fired, appends the wake, then marks it delivered", async () => { + const { store, calls, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => ({ + body: { result: "satisfied", facts: { status: "COMPLETED", durationMs: 4200 } }, + })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + const { notified, notifyFired } = fakeNotifyFired(); + + const result = await runWatchTick( + PAYLOAD, + deps({ store, fetch, deliver, reschedule, notifyFired }) + ); + + expect(result).toEqual({ outcome: "fired" }); + expect(row.status).toBe("fired"); + expect(row.deliveryStatus).toBe("delivered"); + expect(triggers).toHaveLength(0); + + // One wake, on the watch's own chat, carrying the check's facts and a + // stable id. + expect(appends).toHaveLength(1); + expect(appends[0]?.chatId).toBe("chat_1"); + expect(appends[0]?.action).toMatchObject({ + type: "watch.fired", + id: "watch:watch_1:fired", + watchId: "watch_1", + identity: "run_finished:run_a1", + facts: { verified: true, status: "COMPLETED", durationMs: 4200 }, + note: "tell me when the receipt run finishes", + }); + // Delivery is marked only after the append, and under the claim's own token. + expect(calls.delivered).toEqual([{ id: "watch_1", claimId: "wdc_1" }]); + // And the webapp is told once, so the configured alerts go out. + expect(notified).toEqual(["watch_1"]); + }); + + // The consent lives on the row and travels in the wake: the tick never acts on + // it (an investigation is the wake turn's business, §6), it only carries it. + it("the wake carries the row's investigate-on-attention consent", async () => { + const { store } = fakeStore(watchRow({ investigateOnAttention: true })); + const { fetch } = fakeFetch(() => ({ body: { result: "satisfied" } })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + expect(appends[0]?.action.investigateOnAttention).toBe(true); + }); + + it("a failing fired notification does not fail the tick", async () => { + const { store, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => ({ body: { result: "satisfied" } })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + const { notified, notifyFired } = fakeNotifyFired({ throws: true }); + + const result = await runWatchTick( + PAYLOAD, + deps({ store, fetch, deliver, reschedule, notifyFired }) + ); + + // The alert is best-effort; the wake is what the tick guarantees. + expect(result).toEqual({ outcome: "fired" }); + expect(notified).toEqual(["watch_1"]); + expect(appends).toHaveLength(1); + expect(row.deliveryStatus).toBe("delivered"); + }); + + it("an expiry does not notify: only a fired watch alerts", async () => { + const { store, row } = fakeStore(watchRow({ tickCount: 3 })); + const { fetch } = fakeFetch(() => ({ body: { result: "pending" } })); + const { deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + const { notified, notifyFired } = fakeNotifyFired(); + + const result = await runWatchTick( + payloadFor(4), + deps({ + store, + fetch, + deliver, + reschedule, + notifyFired, + now: new Date("2026-01-01T13:00:01.000Z"), + }) + ); + + expect(result.outcome).toBe("expired"); + expect(row.status).toBe("expired"); + expect(notified).toEqual([]); + }); + + it("a run that started and finished between two ticks fires, it does not go terminal_unsatisfied", async () => { + // The endpoint sees the run in a terminal-success state and says satisfied; + // the tick must not read "it isn't running any more" as a miss. + const { store, row } = fakeStore( + watchRow({ tickCount: 1, spec: { ...watchRow().spec, kind: "run_start" } as Watch["spec"] }) + ); + const { fetch } = fakeFetch(() => ({ + body: { result: "satisfied", facts: { startedAt: "2026-01-01T11:59:00.000Z" } }, + })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + const result = await runWatchTick(payloadFor(2), deps({ store, fetch, deliver, reschedule })); + + expect(result.outcome).toBe("fired"); + expect(row.status).toBe("fired"); + expect(appends[0]?.action.type).toBe("watch.fired"); + }); + + it("crash between the transition and the append: the invocation throws, and the retry delivers only once", async () => { + const { store, calls, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => ({ body: { result: "satisfied", facts: { runs: 0 } } })); + // The first append fails — the platform must retry the whole invocation. + const { appends, deliver } = fakeDeliver({ throwOnce: true }); + const { reschedule } = fakeReschedule(); + const d = deps({ store, fetch, deliver, reschedule }); + + await expect(runWatchTick(PAYLOAD, d)).rejects.toThrow("session append failed"); + + // The row is terminal with the delivery still owed, and nothing was marked. The + // failed append gave the claim back, so the retry doesn't have to wait it out. + expect(row.status).toBe("fired"); + expect(row.deliveryStatus).toBe("pending"); + expect(calls.released).toEqual([{ id: "watch_1", claimId: "wdc_1" }]); + expect(calls.delivered).toHaveLength(0); + expect(appends).toHaveLength(0); + + // The retry takes the delivery-only path: no second transition, one append. + const retry = await runWatchTick(PAYLOAD, d); + expect(retry).toEqual({ outcome: "delivered_only" }); + expect(calls.transition).toHaveLength(1); + expect(appends).toHaveLength(1); + expect(row.deliveryStatus).toBe("delivered"); + }); + + it("two concurrent invocations of the same generation wake the chat exactly once", async () => { + // The tick claim is resumable, so two invocations that own the same generation + // can both pass it, both see the condition satisfied, and both reach the + // delivery — one as the transition's winner, the other finding terminal + owed. + // Only the delivery claim keeps that from being two wakes. + const { store, calls, row } = fakeStore(watchRow({ tickCount: 3 })); + const { fetch } = fakeFetch(() => ({ body: { result: "satisfied", facts: { runs: 1 } } })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + const { notified, notifyFired } = fakeNotifyFired(); + const d = deps({ store, fetch, deliver, reschedule, notifyFired }); + + const outcomes = ( + await Promise.all([runWatchTick(payloadFor(4), d), runWatchTick(payloadFor(4), d)]) + ).map((result) => result.outcome); + + // BOTH reached the delivery — that is the race — and only one got the claim. + expect(calls.deliveryClaims).toHaveLength(2); + // ONE wake, one delivery mark, one alert. + expect(appends).toHaveLength(1); + expect(calls.delivered).toEqual([{ id: "watch_1", claimId: "wdc_1" }]); + expect(notified).toEqual(["watch_1"]); + // One of them resolved the row; the other found the delivery already taken. + expect(calls.transition).toHaveLength(2); + expect(outcomes).toContain("fired"); + expect(outcomes).toContain("already_delivering"); + expect(row).toMatchObject({ status: "fired", deliveryStatus: "delivered" }); + }); + + it("a live delivery claim is left alone, and a dead one is recovered", async () => { + // A deliverer that claimed the wake and died leaves the row `delivering`. While + // the claim is fresh it is somebody's to hold; once it is stale nothing else + // will ever wake the chat, so the next invocation takes it. + const fresh = fakeStore( + watchRow({ + status: "fired", + deliveryStatus: "delivering", + deliveryClaimedAt: NOW, + firedAt: NOW, + }) + ); + const { fetch } = fakeFetch(() => ({ body: { result: "satisfied" } })); + const { reschedule } = fakeReschedule(); + const live = fakeDeliver(); + + expect( + await runWatchTick( + { ...payloadFor(0), deliverOnly: true }, + deps({ store: fresh.store, fetch, deliver: live.deliver, reschedule }) + ) + ).toEqual({ outcome: "already_delivering" }); + expect(live.appends).toHaveLength(0); + expect(fresh.row.deliveryStatus).toBe("delivering"); + + const dead = fakeStore( + watchRow({ + status: "fired", + deliveryStatus: "delivering", + deliveryClaimedAt: new Date(NOW.getTime() - 60 * 60 * 1000), + firedAt: NOW, + }) + ); + const recovered = fakeDeliver(); + + expect( + await runWatchTick( + { ...payloadFor(0), deliverOnly: true }, + deps({ store: dead.store, fetch, deliver: recovered.deliver, reschedule }) + ) + ).toEqual({ outcome: "delivered_only" }); + expect(recovered.appends).toHaveLength(1); + expect(dead.row.deliveryStatus).toBe("delivered"); + }); + + it("a terminal, already-delivered watch does nothing at all", async () => { + const { store, calls } = fakeStore(watchRow({ status: "fired", deliveryStatus: "delivered" })); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ body: { result: "satisfied" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + expect(result).toEqual({ outcome: "already_terminal" }); + expect(fetchCalls).toHaveLength(0); + expect(appends).toHaveLength(0); + expect(triggers).toHaveLength(0); + expect(calls.transition).toHaveLength(0); + }); + + it("unavailable: a failed tick, never a fire and never a miss", async () => { + const { store, calls, row } = fakeStore( + watchRow({ tickCount: 2, lastResult: { pending: 12 } }) + ); + const { fetch } = fakeFetch(() => ({ status: 503, body: { error: "clickhouse is down" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick(payloadFor(3), deps({ store, fetch, deliver, reschedule })); + + expect(result).toEqual({ outcome: "unavailable", tickCount: 3 }); + expect(row.status).toBe("active"); + expect(calls.transition).toHaveLength(0); + expect(appends).toHaveLength(0); + // Still watching, and the last good observation is kept. + expect(triggers[0]?.options.idempotencyKey).toBe("watch:watch_1:tick:4"); + expect(row.lastResult).toMatchObject({ checkFailed: true, previous: { pending: 12 } }); + }); + + it("access_revoked: exits without resolving, delivering, or rescheduling", async () => { + // The endpoint cancelled the row itself before answering, so there is nothing + // left for the tick to transition — and a cancellation is never narrated. + const { store, calls, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => ({ + status: 403, + body: { error: "no access", code: "access_revoked" }, + })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + expect(result).toEqual({ outcome: "revoked" }); + expect(calls.transition).toHaveLength(0); + expect(calls.checks).toHaveLength(0); + expect(appends).toHaveLength(0); + expect(triggers).toHaveLength(0); + // The chain stops here: the generation was claimed, and nothing follows it. + expect(row.tickCount).toBe(1); + }); + + it("an unrecognized 403 is a failed check, not a silent exit: it reschedules", async () => { + // Anything but access_revoked/cancelled/not_found leaves the row active, so it + // must keep ticking (and hit its own deadline) rather than be abandoned. + const { store, calls, row } = fakeStore(watchRow({ tickCount: 1 })); + const { fetch } = fakeFetch(() => ({ status: 403, body: { error: "nope" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick(payloadFor(2), deps({ store, fetch, deliver, reschedule })); + + expect(result).toEqual({ outcome: "unavailable", tickCount: 2 }); + expect(row.status).toBe("active"); + expect(calls.transition).toHaveLength(0); + expect(appends).toHaveLength(0); + expect(triggers[0]?.options.idempotencyKey).toBe("watch:watch_1:tick:3"); + expect(row.lastResult).toMatchObject({ checkFailed: true }); + }); + + it("a late duplicate of an old generation claims nothing: the chain can't fork", async () => { + const { store, calls, row } = fakeStore(watchRow({ tickCount: 3 })); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ body: { result: "pending" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + const d = deps({ store, fetch, deliver, reschedule }); + + // Generation 4 is accepted and schedules 5, which then claims its own. + expect(await runWatchTick(payloadFor(4), d)).toEqual({ outcome: "pending", tickCount: 4 }); + expect(await runWatchTick(payloadFor(5), d)).toEqual({ outcome: "pending", tickCount: 5 }); + + // A duplicate of generation 4 arrives late — its successor has already run, so + // it must not check, record, or start a second chain. + const late = await runWatchTick(payloadFor(4), d); + + expect(late).toEqual({ outcome: "stale" }); + expect(fetchCalls).toHaveLength(2); + expect(calls.checks).toHaveLength(2); + expect(triggers.map((trigger) => trigger.options.idempotencyKey)).toEqual([ + "watch:watch_1:tick:5", + "watch:watch_1:tick:6", + ]); + expect(calls.transition).toHaveLength(0); + expect(appends).toHaveLength(0); + expect(row.tickCount).toBe(5); + }); + + it("a retry of a generation that crashed before its successor was accepted resumes it", async () => { + const { store, calls, row } = fakeStore(watchRow({ tickCount: 3 })); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ body: { result: "pending" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + // The first attempt claims generation 4, checks — and dies scheduling its + // successor, so nobody has generation 5. + let failNextTrigger = true; + const d = deps({ + store, + fetch, + deliver, + reschedule: async (next, options) => { + if (failNextTrigger) { + failNextTrigger = false; + throw new Error("the trigger failed"); + } + return reschedule(next, options); + }, + }); + + await expect(runWatchTick(payloadFor(4), d)).rejects.toThrow("the trigger failed"); + expect(row.tickCount).toBe(4); + expect(triggers).toHaveLength(0); + + // The platform retries the SAME generation. It has to be able to resume: the + // row is already on 4, and refusing the claim would leave the chain with no + // successor at all. + const retry = await runWatchTick(payloadFor(4), d); + + expect(retry).toEqual({ outcome: "pending", tickCount: 4 }); + expect(calls.claims).toEqual([ + { id: "watch_1", generation: 4 }, + { id: "watch_1", generation: 4 }, + ]); + expect(fetchCalls).toHaveLength(2); + // One successor, and its key is a pure function of the generation — so the two + // attempts can only ever produce the same one. + expect(triggers).toHaveLength(1); + expect(triggers[0]?.payload).toEqual(payloadFor(5)); + expect(triggers[0]?.options.idempotencyKey).toBe("watch:watch_1:tick:5"); + expect(row.status).toBe("active"); + expect(row.tickCount).toBe(4); + expect(appends).toHaveLength(0); + }); + + it("a resumed generation past the deadline still resolves exactly once", async () => { + // The resume re-runs the whole tick, including the terminal transition. The + // guard on `active` is what makes that safe. + const { store, calls, row } = fakeStore(watchRow({ tickCount: 12 })); + const { fetch } = fakeFetch(() => ({ body: { result: "satisfied", facts: { runs: 1 } } })); + const { appends, deliver } = fakeDeliver({ throwOnce: true }); + const { reschedule } = fakeReschedule(); + const d = deps({ store, fetch, deliver, reschedule }); + + await expect(runWatchTick(payloadFor(13), d)).rejects.toThrow("session append failed"); + const retry = await runWatchTick(payloadFor(13), d); + + expect(retry).toEqual({ outcome: "delivered_only" }); + expect(calls.transition).toHaveLength(1); + expect(appends).toHaveLength(1); + expect(row.status).toBe("fired"); + expect(row.deliveryStatus).toBe("delivered"); + }); + + it("deliverOnly: wakes a resolved watch without claiming, checking, or rescheduling", async () => { + const { store, calls, row } = fakeStore( + watchRow({ + status: "fired", + deliveryStatus: "pending", + firedAt: NOW, + lastResult: { runs: 2 }, + }) + ); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ body: { result: "satisfied" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick( + { ...payloadFor(0), deliverOnly: true }, + deps({ store, fetch, deliver, reschedule }) + ); + + expect(result).toEqual({ outcome: "delivered_only" }); + expect(calls.claims).toHaveLength(0); + expect(fetchCalls).toHaveLength(0); + expect(triggers).toHaveLength(0); + expect(appends).toHaveLength(1); + expect(appends[0]?.action).toMatchObject({ type: "watch.fired", id: "watch:watch_1:fired" }); + expect(row.deliveryStatus).toBe("delivered"); + }); + + it("deliverOnly on a watch that is still active decides nothing", async () => { + const { store, calls, row } = fakeStore(watchRow()); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ body: { result: "satisfied" } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick( + { ...payloadFor(0), deliverOnly: true }, + deps({ store, fetch, deliver, reschedule }) + ); + + expect(result).toEqual({ outcome: "nothing_to_deliver" }); + expect(calls.claims).toHaveLength(0); + expect(calls.transition).toHaveLength(0); + expect(fetchCalls).toHaveLength(0); + expect(triggers).toHaveLength(0); + expect(appends).toHaveLength(0); + expect(row.status).toBe("active"); + expect(row.tickCount).toBe(0); + }); + + it("expiry with an unavailable final check: the watch still expires, and the facts say it couldn't be verified", async () => { + const { store, row } = fakeStore( + watchRow({ + tickCount: 30, + lastCheckedAt: new Date("2026-01-01T12:50:00.000Z"), + lastResult: { pending: 41 }, + }) + ); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ + status: 500, + body: { error: "metrics unavailable" }, + })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + // Past expiresAt (13:00) — the row is the authority, so this is the final check. + const result = await runWatchTick( + payloadFor(31), + deps({ store, fetch, deliver, reschedule, now: new Date("2026-01-01T13:00:01.000Z") }) + ); + + expect(JSON.parse(String(fetchCalls[0]?.init?.body))).toEqual({ final: true }); + expect(result).toEqual({ outcome: "expired" }); + expect(row.status).toBe("expired"); + expect(triggers).toHaveLength(0); + expect(appends[0]?.action).toMatchObject({ + type: "watch.expired", + id: "watch:watch_1:expired", + facts: { + verified: false, + reason: "unverified_at_expiry", + lastObservedAt: "2026-01-01T12:50:00.000Z", + lastObservation: { pending: 41 }, + }, + }); + }); + + it("expiry with a pending final check: it expires as not met, verified", async () => { + const { store, row } = fakeStore(watchRow({ tickCount: 12 })); + const { fetch } = fakeFetch(() => ({ body: { result: "pending", facts: { pending: 7 } } })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick( + payloadFor(13), + deps({ store, fetch, deliver, reschedule, now: new Date("2026-01-01T13:30:00.000Z") }) + ); + + expect(result).toEqual({ outcome: "expired" }); + expect(row.status).toBe("expired"); + expect(triggers).toHaveLength(0); + expect(appends[0]?.action.facts).toMatchObject({ + verified: true, + reason: "not_met_by_expiry", + pending: 7, + }); + }); + + it("terminal_unsatisfied: stops as an expiry that says it can never happen now", async () => { + const { store, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => ({ + body: { result: "terminal_unsatisfied", facts: { status: "CANCELED" } }, + })); + const { appends, deliver } = fakeDeliver(); + const { triggers, reschedule } = fakeReschedule(); + + const result = await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + expect(result).toEqual({ outcome: "expired" }); + expect(row.status).toBe("expired"); + expect(triggers).toHaveLength(0); + expect(appends[0]?.action).toMatchObject({ + type: "watch.expired", + facts: { verified: true, reason: "terminal_unsatisfied", status: "CANCELED" }, + }); + }); + + it("a watch that no longer exists is a no-op", async () => { + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ body: {} })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + const store: WatchTickStore = { + getWatch: async () => null, + claimWatchTick: async () => null, + transitionWatchCondition: async () => null, + claimWatchDelivery: async () => null, + releaseWatchDelivery: async () => null, + markWatchDelivered: async () => null, + recordWatchCheck: async () => null, + }; + + const result = await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + expect(result).toEqual({ outcome: "missing" }); + expect(fetchCalls).toHaveLength(0); + expect(appends).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// The resolution model (§4.2) and the window boundary (§7.4, binding) +// +// The tick's job is unchanged; what it RECORDS is not. Every terminal transition +// now writes a resolution and the observation that came with it, atomically with +// the frozen facts — and the wake carries both, while the wire keeps its +// as-built two-value encoding (§7.5). +// --------------------------------------------------------------------------- + +describe("the resolution model", () => { + const OBSERVED = { + kind: "run_finished" as const, + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 4200, + }; + + it("records condition_met with the observation, and keeps the wire encoding", async () => { + const { store, calls, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => ({ + body: { + result: "satisfied", + facts: { outcome: "COMPLETED_WITH_ERRORS" }, + observed: OBSERVED, + }, + })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + // One statement carried all three halves of the answer. + expect(calls.transition).toEqual([ + { + id: "watch_1", + resolution: "condition_met", + observedOutcome: OBSERVED, + lastResult: { verified: true, outcome: "COMPLETED_WITH_ERRORS" }, + }, + ]); + expect(row.resolution).toBe("condition_met"); + + // §7.5: the id and type are unchanged, and the meaning rides alongside. + expect(appends[0]?.action).toMatchObject({ + type: "watch.fired", + id: "watch:watch_1:fired", + resolution: "condition_met", + observed: OBSERVED, + }); + }); + + it("records condition_impossible, not a plain expiry", async () => { + const { store, calls, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => ({ + body: { result: "terminal_unsatisfied", facts: { status: "CANCELED" } }, + })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + expect(calls.transition[0]).toMatchObject({ resolution: "condition_impossible" }); + expect(row.resolution).toBe("condition_impossible"); + // Still addressed as `expired` on the wire. + expect(appends[0]?.action).toMatchObject({ + id: "watch:watch_1:expired", + resolution: "condition_impossible", + }); + }); + + // Binding: the final evaluation is a REAL evaluation. A watch whose condition + // becomes true exactly at the deadline resolves `condition_met`, not + // `window_completed`. + it("lets the boundary check still resolve condition_met", async () => { + const { store, calls } = fakeStore(watchRow({ tickCount: 12 })); + const { fetch, calls: fetchCalls } = fakeFetch(() => ({ + body: { result: "satisfied", facts: { pending: 0 } }, + })); + const { deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + const result = await runWatchTick( + payloadFor(13), + deps({ store, fetch, deliver, reschedule, now: new Date("2026-01-01T13:30:00.000Z") }) + ); + + // The endpoint was told this is the final evaluation… + expect(JSON.parse(String(fetchCalls[0]?.init?.body))).toEqual({ final: true }); + // …and it still resolved as met. + expect(calls.transition[0]).toMatchObject({ resolution: "condition_met" }); + expect(result).toEqual({ outcome: "fired" }); + }); + + it("lets the boundary check still resolve condition_impossible", async () => { + const { store, calls } = fakeStore(watchRow({ tickCount: 12 })); + const { fetch } = fakeFetch(() => ({ + body: { result: "terminal_unsatisfied", facts: { status: "CANCELED" } }, + })); + const { deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + await runWatchTick( + payloadFor(13), + deps({ store, fetch, deliver, reschedule, now: new Date("2026-01-01T13:30:00.000Z") }) + ); + + expect(calls.transition[0]).toMatchObject({ resolution: "condition_impossible" }); + }); + + it("only a pending or unavailable boundary check becomes window_completed", async () => { + for (const body of [ + { result: "pending" as const, facts: { pending: 7 } }, + // `unavailable` arrives as a non-result: the check itself couldn't run. + undefined, + ]) { + const { store, calls } = fakeStore(watchRow({ tickCount: 12 })); + const { fetch } = fakeFetch(() => + body ? { body } : { status: 500, body: { error: "clickhouse is down" } } + ); + const { deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + await runWatchTick( + payloadFor(13), + deps({ store, fetch, deliver, reschedule, now: new Date("2026-01-01T13:30:00.000Z") }) + ); + + expect(calls.transition[0]).toMatchObject({ resolution: "window_completed" }); + } + }); + + // Before the deadline those two resolve NOTHING — the watch keeps its state. + it("resolves nothing on a pending or unavailable check inside the window", async () => { + for (const body of [{ result: "pending" as const, facts: {} }, undefined]) { + const { store, calls, row } = fakeStore(watchRow()); + const { fetch } = fakeFetch(() => + body ? { body } : { status: 503, body: { error: "down" } } + ); + const { deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + await runWatchTick(PAYLOAD, deps({ store, fetch, deliver, reschedule })); + + expect(calls.transition).toHaveLength(0); + expect(row.status).toBe("active"); + expect(row.resolution ?? null).toBeNull(); + } + }); + + it("carries an unverified observation through a window that could not be confirmed", async () => { + const { store, calls } = fakeStore(watchRow({ tickCount: 12 })); + // The endpoint answered; the CHECK is what couldn't run, so it says so and + // hands back the observation that carries `verified: false`. + const { fetch } = fakeFetch(() => ({ + body: { + result: "unavailable", + error: "metrics unavailable", + observed: { kind: "backlog_drain", verified: false, depth: null }, + }, + })); + const { appends, deliver } = fakeDeliver(); + const { reschedule } = fakeReschedule(); + + await runWatchTick( + payloadFor(13), + deps({ store, fetch, deliver, reschedule, now: new Date("2026-01-01T13:30:00.000Z") }) + ); + + expect(calls.transition[0]).toMatchObject({ + resolution: "window_completed", + observedOutcome: { kind: "backlog_drain", verified: false }, + }); + expect(appends[0]?.action.facts).toMatchObject({ + verified: false, + reason: "unverified_at_expiry", + }); + }); +}); diff --git a/internal-packages/dashboard-agent/src/watch-tick.ts b/internal-packages/dashboard-agent/src/watch-tick.ts new file mode 100644 index 00000000000..71f6412bfe2 --- /dev/null +++ b/internal-packages/dashboard-agent/src/watch-tick.ts @@ -0,0 +1,713 @@ +import { + claimWatchDelivery, + claimWatchTick, + createDashboardAgentDb, + getWatch, + isTerminalWatchStatus, + isWatchDeliveryOwed, + markWatchDelivered, + recordWatchCheck, + releaseWatchDelivery, + transitionWatchCondition, + WATCH_DELIVERY_CLAIM_STALE_MS, + type DashboardAgentDbClient, + type PersistedWatchSpec, + type Watch, + type WatchDeliveryClaim, +} from "@internal/dashboard-agent-db"; +import { + watchResolutionForCheck, + watchResolutionToWireStatus, + type WatchCheckResult, + type WatchObservedOutcome, + type WatchResolution, +} from "@internal/dashboard-agent-contracts"; +import { logger, sessions, task, tasks } from "@trigger.dev/sdk"; +import type { WatchWakeAction } from "./dashboard-agent"; + +/** + * The watcher — one invocation is one tick of one watch ("tell me when X + * happens"). Triggered by the webapp when the watch is created, and by itself + * on every reschedule until the condition resolves or the watch runs out of + * time. + * + * There is NO model in a tick. The condition is evaluated by the webapp (which + * has the data and the access checks); the tick's whole job is the lifecycle: + * ask, record, and — exactly once — wake the chat. The narration is the agent's + * job, and it happens in the agent run, from the wake action this task appends + * to the chat's `in` stream. + * + * Same shape as the eval task: its own lazy connection pool (ticks are their own + * runs and land on other workers than the agent), and the whole algorithm behind + * an injectable `deps` seam so the tests drive it with a fake store, a fake + * fetch, and a fake session append instead of mocks. + * + * Ordering rules that the tests pin, all of them load-bearing: + * + * - The row is the authority on expiry, not the clock the check ran on. + * - `unavailable` is never read as true and never as false: the check itself + * couldn't run, so the watch keeps its state and tries again. + * - Every invocation owns ONE generation, carried in the payload and claimed + * atomically before anything else happens. The claim is resumable: a retry of + * the invocation that owns a generation re-runs it (the successor's idempotency + * key is derived from the generation, so the chain still can't fork), while a + * late duplicate of an older generation claims nothing and exits `stale`. A + * claim that refused to resume would leave a crashed generation with no + * successor and the watch unchecked until its deadline. + * - The terminal transition is atomic and one-way (`active` → fired/expired, + * delivery `pending`), and `markWatchDelivered` happens ONLY after the session + * append is acknowledged. Anything failing before that ack throws, so the + * platform retries the invocation; the retry finds terminal + an owed delivery + * and performs the delivery alone — which is why the terminal branch runs BEFORE + * the claim. + * - The wake itself is claimed atomically (`pending` → `delivering`) and only the + * claim's winner appends. Two invocations that both get past the tick claim — a + * resumable generation makes that possible — then race on the terminal + * transition, and the loser re-reads a row whose delivery is still owed; without + * the delivery claim they would BOTH wake the chat, since the action id dedups + * only through a read-then-write on the transcript. The claim is fenced by a + * token, so releasing it and marking it delivered can only ever be done by the + * deliverer that still holds it. + */ + +/** What the webapp triggers on creation, and what a tick re-triggers on itself. */ +export type WatchTickPayload = { + watchId: string; + /** The watch's own token, minted by the webapp. Authorizes the check endpoint. */ + token: string; + apiOrigin: string; + /** + * The tick generation this invocation owns, starting at 1. Produced only by the + * webapp's `scheduleWatchTick` (watch creation) and by this task's own + * reschedule, and claimed once via `claimWatchTick`. + */ + tick: number; + /** + * Delivery only: the row has ALREADY been resolved by the webapp, and all this + * invocation does is wake the chat and mark the delivery. No claim, no check, no + * reschedule — `tick` is ignored. + * + * This is the seam the webapp's watch sweep uses. The webapp owns the outcome + * (it re-authorizes the user and runs the final check); appending to a chat's + * `in` stream is the agent project's capability, so the delivery is handed back + * here instead of being duplicated there. + */ + deliverOnly?: boolean; +}; + +/** The watch rows this task reads and writes, behind an interface so tests can fake it. */ +export type WatchTickStore = { + getWatch(params: { id: string }): Promise; + claimWatchTick(params: { id: string; generation: number }): Promise; + transitionWatchCondition(params: { + id: string; + resolution: WatchResolution; + observedOutcome?: WatchObservedOutcome | null; + lastResult?: Record | null; + }): Promise; + /** + * Take the wake. Only the row this returns may be appended to the chat, and only + * while the returned `claimId` is still the row's — that token fences the two + * writes below. + */ + claimWatchDelivery(params: { id: string; staleBefore: Date }): Promise; + /** Hand the wake back after a failed append, so the retry can re-claim it. */ + releaseWatchDelivery(params: { id: string; claimId: string }): Promise; + markWatchDelivered(params: { id: string; claimId: string }): Promise; + recordWatchCheck(params: { + id: string; + lastResult?: Record | null; + }): Promise<{ tickCount: number; lastCheckedAt: Date | null } | null>; +}; + +/** + * Resolving a watch and waking the chat — kept as its own deps shape because both + * the full tick and a delivery-only invocation go through the same transition + + * append + mark sequence. + */ +export type WatchDeliveryDeps = { + store: Pick< + WatchTickStore, + | "getWatch" + | "transitionWatchCondition" + | "claimWatchDelivery" + | "releaseWatchDelivery" + | "markWatchDelivered" + >; + /** Append the wake to the chat's `in` stream. Must throw if the append fails. */ + deliver: (args: { chatId: string; action: WatchWakeAction; watch: Watch }) => Promise; + /** + * Tell the webapp a watch fired, so it can send the user's configured alerts + * (email/Slack/webhook). Best-effort: a failure here must never fail the tick — + * the wake in the chat is the delivery that matters. + */ + notifyFired: (watchId: string) => Promise; + now?: () => Date; +}; + +export type WatchTickDeps = WatchDeliveryDeps & { + store: WatchTickStore; + /** Injected so tests can assert the request the check endpoint receives. */ + fetch: typeof fetch; + /** Trigger the next tick. */ + reschedule: ( + payload: WatchTickPayload, + options: { delay: string; idempotencyKey: string } + ) => Promise; +}; + +/** What one tick did, for the run's output and the tests. */ +export type WatchTickOutcome = + | "missing" + | "already_terminal" + | "delivered_only" + // The wake is owed but another invocation holds the delivery claim, so this one + // must not append: exactly one wake reaches the chat. + | "already_delivering" + // The row has moved past the generation this invocation carries: a late + // duplicate whose successor already ran. + | "stale" + // A `deliverOnly` invocation on a row that isn't terminal yet: nothing to wake + // the chat about, and this invocation must not decide an outcome. + | "nothing_to_deliver" + | "revoked" + | "unavailable" + | "pending" + | "fired" + | "expired"; + +export type WatchTickResult = { outcome: WatchTickOutcome; tickCount?: number }; + +/** The check endpoint's answer, normalized. */ +type CheckOutcome = + | { + kind: "result"; + result: WatchCheckResult; + facts?: Record; + /** What the check SAW — the second half of a resolved result (§4.2). */ + observed?: WatchObservedOutcome; + } + // The row is already over and the webapp knows it: the tick exits without + // transitioning or delivering. + | { kind: "revoked"; code?: string } + // The check itself couldn't run. Never true, never false. + | { kind: "unavailable"; detail?: string; observed?: WatchObservedOutcome }; + +/** + * Codes that mean the ROW is no longer active, so there is nothing left for the + * tick to transition or deliver: + * + * - `access_revoked` — the endpoint re-authorized the user, failed, and cancelled + * the watch itself before returning (a cancellation is never narrated). + * - `cancelled` — the row was already cancelled (chat deleted, user asked). + * - `not_found` — the row is gone. + * + * Anything else non-2xx is a failed check, i.e. `unavailable`: the tick records + * the failure and keeps watching, and the row's own deadline (or the expiry + * sweeper) ends the watch. That includes an unrecognized 401/403 — a bad token or + * an unexpected refusal must not leave the row active forever, holding one of the + * chat's watch slots. + */ +const REVOKED_CODES = new Set(["access_revoked", "cancelled", "not_found"]); + +async function postCheck( + deps: WatchTickDeps, + payload: WatchTickPayload, + final: boolean +): Promise { + const origin = payload.apiOrigin.replace(/\/$/, ""); + let response: Response; + try { + response = await deps.fetch( + `${origin}/api/v1/dashboard-agent/watches/${encodeURIComponent(payload.watchId)}/check`, + { + method: "POST", + headers: { + Authorization: `Bearer ${payload.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(final ? { final: true } : {}), + } + ); + } catch (error) { + return { kind: "unavailable", detail: (error as Error).message }; + } + + const body = (await response.json().catch(() => undefined)) as + | { + result?: WatchCheckResult; + facts?: Record; + observed?: WatchObservedOutcome; + code?: string; + error?: string; + } + | undefined; + + if (!response.ok) { + if (body?.code && REVOKED_CODES.has(body.code)) return { kind: "revoked", code: body.code }; + return { + kind: "unavailable", + detail: body?.error ?? `status ${response.status}${body?.code ? ` (${body.code})` : ""}`, + observed: body?.observed, + }; + } + + if (!body?.result) return { kind: "unavailable", detail: "the check returned no result" }; + if (body.result === "unavailable") { + return { kind: "unavailable", detail: body.error, observed: body.observed }; + } + return { kind: "result", result: body.result, facts: body.facts, observed: body.observed }; +} + +/** + * Tell the webapp the watch fired. The webapp owns the alert fan-out; this call + * only says "it happened", and the row it reads is the authority on the rest. + * + * Same token as the check endpoint. No retry loop: the endpoint dedupes on the + * watch, so a later tick or invocation retry can repeat it harmlessly, and losing + * the alert is better than losing the wake. + */ +async function postFired(payload: WatchTickPayload): Promise { + const origin = payload.apiOrigin.replace(/\/$/, ""); + const response = await fetch( + `${origin}/api/v1/dashboard-agent/watches/${encodeURIComponent(payload.watchId)}/fired`, + { + method: "POST", + headers: { + Authorization: `Bearer ${payload.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: "{}", + } + ); + + if (!response.ok) { + throw new Error(`the fired callback returned ${response.status}`); + } +} + +/** Facts for a watch that resolved on a check. */ +function firedFacts(facts: Record | undefined): Record { + return { verified: true, ...(facts ?? {}) }; +} + +/** + * Facts for an expiry. When the FINAL check came back `unavailable` the watch + * still expires — but the narration must not claim the thing didn't happen, so + * the facts say the condition couldn't be verified at expiry and carry the last + * observation we do have: the row's `lastCheckedAt` / `lastResult` pair, which is + * only ever written together, by the check that observed it. + */ +export function expiredFacts( + watch: Watch, + args: { + verified: boolean; + reason: string; + facts?: Record; + } +): Record { + return { + verified: args.verified, + reason: args.reason, + expiredAt: watch.expiresAt.toISOString(), + checks: watch.tickCount, + ...(args.verified + ? (args.facts ?? {}) + : { + lastObservedAt: watch.lastCheckedAt?.toISOString(), + lastObservation: watch.lastResult, + }), + }; +} + +/** + * The wake, as the agent receives it. + * + * The transport keeps its as-built two-value encoding (§7.5, binding): the type + * and the action id still say `fired`/`expired`, so persisted wakes, dedup keys + * and banner render keys stay valid. The RESOLUTION and the OBSERVED OUTCOME + * travel in the payload beside them — that is where the meaning lives now. + */ +function wakeAction(watch: Watch, facts: Record): WatchWakeAction { + const spec = watch.spec as PersistedWatchSpec; + return { + type: watch.status === "fired" ? "watch.fired" : "watch.expired", + // Stable per (watch, outcome): a redelivered wake never narrates twice. + id: `watch:${watch.id}:${watch.status}`, + watchId: watch.id, + identity: watch.identity, + spec, + facts, + resolution: watch.resolution ?? undefined, + observed: watch.observedOutcome ?? undefined, + note: spec.note, + // The consent given at creation. The wake carries it so the narration can + // say the investigation has started — the investigation itself is the wake + // turn's business, never this task's (§6). + investigateOnAttention: watch.investigateOnAttention, + }; +} + +/** + * Wake the chat, then mark the delivery. Returns whether THIS call delivered. + * + * The claim is the gate: `pending → delivering` in one statement, so of two + * deliverers racing on the same resolved row exactly one appends and the other + * returns false. The stable action id is still the second line of defence, but it + * dedups only through a read-then-write on the transcript, which two concurrent + * appends can interleave through — so it can't be the first. + * + * A failed append gives the claim back and rethrows: the invocation fails, the + * platform retries it, and the retry re-claims immediately instead of waiting out + * the stale window. A deliverer that dies without releasing leaves a `delivering` + * row, which the sweep recovers once the claim is stale. + * + * Both of those writes carry the claim's `claimId`, so they only ever touch the + * claim this call owns: a deliverer that hung long enough to be taken over comes + * back to a row holding a different token, and its release and its mark do nothing. + * + * Known residual race: the token fences the DB writes, not the session append + * itself — an owner that hung PAST the stale window can still fire its append + * late, concurrently with the takeover's. Accepted because every layer has to + * fail at once for a duplicate to surface: the claim goes stale only after + * WATCH_DELIVERY_CLAIM_STALE_MS (minutes, vs a delivery that takes seconds), + * the action id is stable across deliverers, and the transcript dedups on it. + */ +async function deliverWake(deps: WatchDeliveryDeps, watch: Watch): Promise { + const now = deps.now?.() ?? new Date(); + const claim = await deps.store.claimWatchDelivery({ + id: watch.id, + staleBefore: new Date(now.getTime() - WATCH_DELIVERY_CLAIM_STALE_MS), + }); + + if (!claim) { + logger.info("dashboard-agent watch wake is already being delivered; skipping", { + watchId: watch.id, + }); + return false; + } + + const { watch: claimed, claimId } = claim; + const facts = (claimed.lastResult ?? {}) as Record; + try { + await deps.deliver({ + chatId: claimed.chatId, + action: wakeAction(claimed, facts), + watch: claimed, + }); + } catch (error) { + await deps.store.releaseWatchDelivery({ id: claimed.id, claimId }); + throw error; + } + await deps.store.markWatchDelivered({ id: claimed.id, claimId }); + + // The alerts the user configured, after the wake and outside its failure path: + // the chat is the delivery this task guarantees, an alert is an extra. + if (claimed.status === "fired") { + try { + await deps.notifyFired(claimed.id); + } catch (error) { + logger.warn("dashboard-agent watch: the fired notification failed", { + watchId: claimed.id, + error: (error as Error).message, + }); + } + } + + return true; +} + +/** + * Resolve the watch and wake the chat. The transition is the gate: only an + * `active` row transitions, so a check that fires at the same moment the sweeper + * expires the watch yields exactly one winner. The loser re-reads the row and + * delivers whatever the winner decided, if that's still owed. + * + */ +export async function resolveAndDeliver( + deps: WatchDeliveryDeps, + watch: Watch, + resolution: WatchResolution, + facts: Record, + observed?: WatchObservedOutcome +): Promise { + const transitioned = await deps.store.transitionWatchCondition({ + id: watch.id, + resolution, + observedOutcome: observed ?? null, + lastResult: facts, + }); + + if (!transitioned) { + // Someone else resolved it. Deliver only if that outcome is still owed — and + // only if the delivery claim is ours to take. + const current = await deps.store.getWatch({ id: watch.id }); + if ( + current && + isTerminalWatchStatus(current.status) && + isWatchDeliveryOwed(current.deliveryStatus) + ) { + const delivered = await deliverWake(deps, current); + return { outcome: delivered ? "delivered_only" : "already_delivering" }; + } + return { outcome: "already_terminal" }; + } + + await deliverWake(deps, transitioned); + return { outcome: watchResolutionToWireStatus(resolution) }; +} + +/** + * One tick. See the module comment for the invariants; the order of the branches + * below IS the algorithm. + */ +export async function runWatchTick( + payload: WatchTickPayload, + deps: WatchTickDeps +): Promise { + const now = deps.now?.() ?? new Date(); + const watch = await deps.store.getWatch({ id: payload.watchId }); + + if (!watch) return { outcome: "missing" }; + + // Terminal already. The only thing left to do is the delivery, if it's owed — + // this is the path a retried invocation takes after a crash between the + // transition and the append. + if (isTerminalWatchStatus(watch.status)) { + if (isWatchDeliveryOwed(watch.deliveryStatus)) { + const delivered = await deliverWake(deps, watch); + return { outcome: delivered ? "delivered_only" : "already_delivering" }; + } + return { outcome: "already_terminal" }; + } + + // Delivery-only invocations never decide anything: if the row isn't terminal, + // there is nothing owed and this exits without claiming a generation. + if (payload.deliverOnly) { + logger.info("dashboard-agent watch delivery has nothing to deliver", { + watchId: watch.id, + status: watch.status, + }); + return { outcome: "nothing_to_deliver" }; + } + + // Claim this invocation's generation. The claim is resumable: it lands on the + // previous generation (a fresh tick) or on this one (a retry of the invocation + // that owns it, resuming after a crash), and refuses only when the row has moved + // past this generation — i.e. this is a late duplicate whose successor already + // ran. A resumed tick re-runs the whole generation, which is safe because every + // write it makes is guarded or keyed (see `claimWatchTick`). + const claimed = await deps.store.claimWatchTick({ + id: watch.id, + generation: payload.tick, + }); + + if (!claimed) { + logger.info("dashboard-agent watch tick is stale; exiting", { + watchId: watch.id, + tick: payload.tick, + tickCount: watch.tickCount, + }); + return { outcome: "stale" }; + } + + // The claimed row is the authority from here on — it is the state this + // invocation owns, re-read inside the same statement that claimed it. + + // The ROW is the authority on expiry, not the check. Past the deadline this is + // the last check the watch gets, and the endpoint is told so. + const final = claimed.expiresAt.getTime() <= now.getTime(); + const check = await postCheck(deps, payload, final); + + if (check.kind === "revoked") { + // The row is cancelled or gone, so there is nothing to transition or deliver. + logger.info("dashboard-agent watch check refused; exiting", { + watchId: claimed.id, + code: check.code, + }); + return { outcome: "revoked" }; + } + + if (check.kind === "unavailable") { + if (!final) { + // A failed tick: the generation is spent, the result isn't trusted. Keep watching. + await deps.store.recordWatchCheck({ + id: claimed.id, + lastResult: { checkFailed: true, detail: check.detail, previous: claimed.lastResult }, + }); + await scheduleNextTick(deps, payload, claimed); + return { outcome: "unavailable", tickCount: payload.tick }; + } + // The final check couldn't run, but the deadline still passed: the watch + // expires, and the narration says the condition couldn't be verified. + // The window completed without a usable final read: `window_completed`, but + // the observation is unverified, so the presentation says the condition + // couldn't be confirmed rather than that it didn't happen. + return resolveAndDeliver( + deps, + claimed, + "window_completed", + expiredFacts(claimed, { verified: false, reason: "unverified_at_expiry" }), + check.observed + ); + } + + // §7.4 (binding): the final evaluation is a real evaluation. `satisfied` and + // `terminal_unsatisfied` resolve the same way at the boundary as before it — + // only `pending` and `unavailable` become `window_completed`. + const resolution = watchResolutionForCheck(check.result, final); + + if (resolution === "condition_met") { + return resolveAndDeliver( + deps, + claimed, + "condition_met", + firedFacts(check.facts), + check.observed + ); + } + + if (resolution === "condition_impossible") { + // Not a failure: it can never happen now. Stop checking and say so. + return resolveAndDeliver( + deps, + claimed, + "condition_impossible", + expiredFacts(claimed, { + verified: true, + reason: "terminal_unsatisfied", + facts: check.facts, + }), + check.observed + ); + } + + if (resolution === "window_completed") { + return resolveAndDeliver( + deps, + claimed, + "window_completed", + expiredFacts(claimed, { verified: true, reason: "not_met_by_expiry", facts: check.facts }), + check.observed + ); + } + + await deps.store.recordWatchCheck({ id: claimed.id, lastResult: check.facts ?? {} }); + await scheduleNextTick(deps, payload, claimed); + return { outcome: "pending", tickCount: payload.tick }; +} + +/** + * Trigger the next tick, `checkEveryMinutes` out. + * + * The successor's generation is `payload.tick + 1` — derived from the generation + * THIS invocation claimed, never from the row's counter, and carried both in the + * payload and in the idempotency key `watch:{id}:tick:{n}`. So a retry that + * re-runs this generation triggers the same successor, the key dedups it, and the + * chain stays single-file; only once that successor has itself claimed does an old + * generation become stale. + */ +async function scheduleNextTick( + deps: WatchTickDeps, + payload: WatchTickPayload, + watch: Watch +): Promise { + const spec = watch.spec as PersistedWatchSpec; + const next = payload.tick + 1; + await deps.reschedule( + { ...payload, tick: next }, + { + delay: `${spec.checkEveryMinutes}m`, + idempotencyKey: `watch:${watch.id}:tick:${next}`, + } + ); +} + +// One connection pool per worker process for the watcher (separate from the +// agent's; ticks are their own runs). +let dbClient: DashboardAgentDbClient | undefined; +export function getWatchDb(): DashboardAgentDbClient { + if (!dbClient) { + const connectionString = process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!connectionString) { + throw new Error( + "DASHBOARD_AGENT_DATABASE_URL (or DATABASE_URL) must be set for the watch task" + ); + } + dbClient = createDashboardAgentDb(connectionString, { max: 2 }); + } + return dbClient; +} + +/** + * The wake, as the agent receives it: one record on the chat's `in` stream + * carrying `trigger: "action"`, which fires the agent's `onAction` hook (and + * nothing else — actions are not turns). The append also ensures a live agent + * run, so a wake reaches a chat whose run has long since idled out. + * + * `metadata` is the agent's `clientData`, rebuilt from the watch's own tenancy + * snapshot. It deliberately carries NO delegated token: a wake narrates what the + * check already established, it doesn't go reading. + */ +export async function appendWakeToSession(args: { + chatId: string; + action: WatchWakeAction; + watch: Watch; +}): Promise { + await sessions.open(args.chatId).in.send({ + kind: "message", + payload: { + chatId: args.chatId, + trigger: "action", + action: args.action, + metadata: { + userId: args.watch.userId, + organizationId: args.watch.organizationId, + projectId: args.watch.projectId, + environmentId: args.watch.environmentId, + // The external ref a consented investigation is scoped by — the same + // one a normal turn carries, so a follow-up turn revises that + // investigation instead of opening a second one. + ...(args.watch.projectRef ? { projectRef: args.watch.projectRef } : {}), + }, + }, + }); +} + +export const watchTick = task({ + id: "dashboard-agent-watch", + // Everything a tick does is idempotent or guarded, and the failure modes are + // transient (the check endpoint, the session append). Retry rather than lose + // the wake; a retry after a transition delivers only. + retry: { maxAttempts: 5 }, + run: async (payload: WatchTickPayload): Promise => { + const { db } = getWatchDb(); + const result = await runWatchTick(payload, { + store: { + getWatch: (params) => getWatch(db, params), + claimWatchTick: (params) => claimWatchTick(db, params), + transitionWatchCondition: (params) => transitionWatchCondition(db, params), + claimWatchDelivery: (params) => claimWatchDelivery(db, params), + releaseWatchDelivery: (params) => releaseWatchDelivery(db, params), + markWatchDelivered: (params) => markWatchDelivered(db, params), + recordWatchCheck: (params) => recordWatchCheck(db, params), + }, + fetch: (input, init) => fetch(input, init), + deliver: appendWakeToSession, + notifyFired: () => postFired(payload), + reschedule: (next, options) => + tasks.trigger("dashboard-agent-watch", next, options), + }); + + logger.info("dashboard-agent watch ticked", { + watchId: payload.watchId, + tick: payload.tick, + outcome: result.outcome, + tickCount: result.tickCount, + }); + + return result; + }, +}); diff --git a/internal-packages/database/prisma/migrations/20260729120000_add_dashboard_agent_watch_alert_type/migration.sql b/internal-packages/database/prisma/migrations/20260729120000_add_dashboard_agent_watch_alert_type/migration.sql new file mode 100644 index 00000000000..e659c8d0a2c --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260729120000_add_dashboard_agent_watch_alert_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "public"."ProjectAlertType" ADD VALUE IF NOT EXISTS 'DASHBOARD_AGENT_WATCH'; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index ca1d868ab04..dc079f6e8dc 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2420,6 +2420,7 @@ enum ProjectAlertType { DEPLOYMENT_FAILURE DEPLOYMENT_SUCCESS ERROR_GROUP + DASHBOARD_AGENT_WATCH } enum ProjectAlertStatus { diff --git a/internal-packages/emails/emails/alert-dashboard-agent-watch.tsx b/internal-packages/emails/emails/alert-dashboard-agent-watch.tsx new file mode 100644 index 00000000000..3f68338ea1b --- /dev/null +++ b/internal-packages/emails/emails/alert-dashboard-agent-watch.tsx @@ -0,0 +1,180 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import React from "react"; +import { z } from "zod"; +import { Footer } from "./components/Footer"; +import { Image } from "./components/Image"; +import { footerAnchor, footerItalic } from "./components/styles"; + +export const AlertDashboardAgentWatchEmailSchema = z.object({ + email: z.literal("alert-dashboard-agent-watch"), + /** The watched condition, as the agent names it (e.g. `run_finished:run_abc`). */ + identity: z.string(), + /** The watch kind, e.g. `run_finished`. */ + kind: z.string(), + /** + * The fact headline, already rendered by the webapp's `watch-presentation.ts` + * — the SAME sentence the chat's wake banner shows, so chat and inbox read + * alike (§6). Optional so an older enqueue still renders something sane. + */ + headline: z.string().optional(), + /** + * The presentation tone that headline was resolved with. Colours the accent + * only; the text keeps its colour, exactly as in the panel. + */ + tone: z.enum(["success", "warning", "error", "neutral"]).optional(), + /** Why the watch exists, in the user's own words. */ + note: z.string(), + firedAt: z.string(), + /** What the check observed, already flattened to label/value pairs. */ + facts: z.array(z.object({ label: z.string(), value: z.string() })), + dashboardLink: z.string().url(), + unsubscribeLink: z.string().url().optional(), + organization: z.string(), + project: z.string(), + environment: z.string(), +}); + +type AlertDashboardAgentWatchEmailProps = z.infer; + +const previewDefaults: AlertDashboardAgentWatchEmailProps = { + email: "alert-dashboard-agent-watch", + identity: "run_finished:run_abc123", + kind: "run_finished", + headline: "Run run_abc123 finished", + tone: "success", + note: "tell me when the nightly invoice run finishes", + firedAt: "2026-07-29T12:00:00.000Z", + facts: [ + { label: "Status", value: "COMPLETED" }, + { label: "Duration", value: "4.2s" }, + ], + dashboardLink: "https://cloud.trigger.dev", + unsubscribeLink: "https://cloud.trigger.dev/unsubscribe", + organization: "my-organization", + project: "my-project", + environment: "Production", +}; + +/** ISO timestamps read badly in prose, so shorten to `2026-07-29 12:00 UTC`. */ +function formatFiredAt(firedAt: string) { + const date = new Date(firedAt); + + if (Number.isNaN(date.getTime())) { + return firedAt; + } + + return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +/** + * The chat's wake banner, in email form. + * + * This template writes NO kind-specific wording of its own. The headline arrives + * already rendered by the webapp's presenter, off the same resolved-result + * mapping the panel's WakeBanner uses — so a failed run says "failed" in the + * inbox too, and there is no good-news kind list anywhere. + * + * Only the accent colour is chosen here, because it is an email palette. + */ +const TONE_COLOR: Record = { + success: "#A8FF53", + warning: "#FBBF24", + error: "#F87171", + neutral: "#D7D9DD", +}; + +/** The fallback headline for a payload written before the presenter existed. */ +function fallbackHeadline(identity: string): string { + return `Your watch has an answer — ${identity}`; +} + +export default function Email(props: AlertDashboardAgentWatchEmailProps) { + const { + identity, + headline, + tone, + note, + firedAt, + facts, + dashboardLink, + unsubscribeLink, + organization, + project, + environment, + } = { ...previewDefaults, ...props }; + + const details = [identity, ...facts.slice(0, 3).map((fact) => `${fact.label}: ${fact.value}`)]; + const accentColor = TONE_COLOR[tone ?? "neutral"] ?? TONE_COLOR.neutral; + + return ( + + + {`${organization}: ${headline ?? fallbackHeadline(identity)}`} + + + +
+ Trigger.dev +
+
+ {/* Fact first, exactly as in the panel: the micro-label carries + the "this is a watch" signal, the headline carries the fact. */} + + Watch update + + + {headline ?? fallbackHeadline(identity)} + + + I was keeping an eye on {project} ({environment}) for you, and this is the answer, + as of {formatFiredAt(firedAt)}. Your note on this watch: “{note}”. + + + {details.join(" · ")} + +
+
+ +
+ + {unsubscribeLink && ( + + + Turn off these alerts + + + )} + +