feat(webapp): dashboard agent — Watch (#4525)
Watch is the agent noticing something later: you ask it to tell you when a condition holds, and it answers when it does — or when it can't any more. A watch is a **durable one-shot promise**. The condition is checked on a schedule by deterministic code (no LLM in the checks), the answer lands in the chat once, and then the watch is over. Ten kinds: three on a run, five on a queue, error recurrence, health recovery. ## Stack Stacked on **#4529** (UI), which is stacked on **#4418** (chat, reports, investigate). Merge those first. **#4516** (storybook gallery) sits on top of this branch. ## How to review [**GUIDEBOOK.md**](https://github.com/triggerdotdev/trigger.dev/blob/feat/dashboard-agent-flows-watch/internal-packages/dashboard-agent/GUIDEBOOK.md) on this branch is the behaviour reference — it states the conditions rather than the code, so you can predict what happens without running anything. "The ten watch kinds, and what makes each fire" and "Creating a watch" describe exactly this PR, and the tables there are the spec the code is written against. ## What's inside - **Ten watch kinds**, one deterministic check each (`dashboardAgentWatch*Checks.ts`), with the spec union in `dashboard-agent-contracts/src/watch.ts`. - **Scheduling** — each watch schedules its own next check; due watches of one `(environment, cadence)` group can be checked together in one batch pass, with a sweep as the backstop for expiry, redelivery and retention. - **Delivery** — the in-chat wake and card, an optional email alert (new `DASHBOARD_AGENT_WATCH` alert channel, so it shows on the project's Alerts page with one-click unsubscribe), and an optional investigation when the outcome needs attention. - **Submission ledger** — `watch_submissions`, keyed `(chat_id, client_request_id)`, so a retried card submission replays the recorded outcome instead of creating a second watch. - **Watch token** — a delayed-execution credential accepted only by the watch endpoints, re-checked against the user's live access on every tick. - **Unread work** — the panel polls for wakes that landed while it was closed, so a chat can go unread and light the launcher dot. ## Key decisions **A check result is a 4-way, and only two of them are verdicts.** `satisfied` / `terminal_unsatisfied` are answers; `pending` and `unavailable` are not. Any exception inside any check is caught in one place and becomes `unavailable` with an unverified observation — a check that failed is never evidence. **A completed window is an answer, and whether it is good or bad news is declared per kind, never inferred.** There is a table for that in the guidebook: `run_failed` completing its window is *good* news ("hasn't failed"), `backlog_drain` completing it is not. One rule overrides the table: a window that completed on an unverified observation is neutral and says only that the watch ended without a confirmed answer. **An unreadable source is never a negative answer** — and, because investigations only open on `attention`, it never starts one either. **Identity is `(chat, project, environment)` plus the condition,** enforced by a partial unique index over active rows (`watches_chat_active_identity_key`), not by the read-then-insert check. Cadence, window, note and `ticks` are deliberately not part of it. Two different chats may watch the same thing — a watch is a promise to a chat. **The server resolves the target's name, whatever the model calls it.** The model can't tell a task queue (`task/<id>`) from a custom queue, so both spellings are tried and the stored one wins — and the rewrite happens **before** identity and before the row is written, so the identity, the checks, the link and the wording all see one spelling. **Freshness fences.** Depth falls back from the live counter to the newest 60 s ClickHouse bucket, which only counts as current within 60 s of now. A non-current reading at or below the *quiet line* is refused as `unavailable` rather than believed, so a stale empty bucket is never read as "drained". The stall streak is the one piece of carried state: it lives in the previous check's facts and *freezes* on an unreadable reading rather than breaking. **Chain reliability.** There is no shared cron — each watch (or batch group) schedules its own next tick, so the failure mode to review is the chain dying. A failed batch check is caught, the next tick is scheduled anyway and the run resolves rather than failing, so the chain survives a check that couldn't run; the sweep re-arms groups and finalizes anything still active past its deadline, even when delivery isn't configured. Wake redelivery is id-deduped rather than conditional, because the sweep can't know whether the user was already told. Access is re-authorized on **every** check against the primary — replica lag would extend access the user has already lost. **Wording lives in one place.** `watch-wording.ts` is read by the card, banner, toast, email and the agent's own narration, and the numbers come from the frozen observation rather than a fresh read, so a retry produces the same sentence. Replay reproduces the **recorded** decision instead of deciding again — the transcript is append-once, so a second decision would contradict it forever. **Cancellation is the ending without an answer** — no resolution, no wake. One exception, decided during testing: a watch the *user* cancelled leaves a single neutral transcript line ("Stopped watching …"), keyed off the watch id so a retry can't repeat it. The other four reasons stay silent. **Email is opt-in and only a fired watch emails.** An expiry is narrated in the chat and nowhere else. Both gates (agent access, a configured email transport) are checked at subscribe time *and* again at delivery, and the subscription outcome is frozen on the ledger row so a retry replays it. Neither gate is a plan check. **One watch offer per turn.** The prompt and the renderer guard this independently — if the turn already proposed a watch card, the action button is dropped, because the card is the better affordance. Two eval cases pin the prompt side: exactly one offer with the line last and the button after it, and zero offers when the rendered card already carries one — deterministic assertions, over a real-model run. ## Testing Unit tests (vitest, testcontainers, no mocks) under `apps/webapp/test/dashboardAgentWatch*.test.ts` and `internal-packages/dashboard-agent/src/watch-*.test.ts` cover the invariants above: the 4-way check results and the freshness fences, identity/dedup and the submission ledger, queue-name resolution, the batch chain surviving a failed check, sweep boundaries and alert-once, tenancy and the watch token's scope, and the wording snapshot. The load-bearing ones were verified by control-breaking the guard first and checking the test goes red. Live-tested end to end against a local stack, following the guidebook: all ten watch kinds firing and expiring, cancellation, the email pair (a fired watch mails, an expired one does not), and watch recovery from a health report.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
The current-worker API now reports each task's queue, so you can see which tasks write to a given queue.
|
||||
@@ -5,4 +5,6 @@ type: feature
|
||||
|
||||
Meet the dashboard agent: a chat in every environment that answers questions about your runs, queues, errors and health with real data and links, replacing Ask AI everywhere it used to appear. Investigate a failed run, an error, a backed-up queue or a run that hasn't started to get a worked-through answer — what happened, why, and how to fix it, with every claim linked to the runs, errors and deploys behind it. It reads your data read-only, works on preview and dev branches with that branch's own data, and reads the same everywhere — dashboard, terminal, editor. A very long chat keeps working: the agent summarises the earlier part and carries on.
|
||||
|
||||
**Watch…** on a run, queue, error or the health report tells you when things change: a run finishes, a queue clears or grows past a number you pick, an error comes back, an environment recovers. The answer arrives in the chat and, if you want, by email, Slack or webhook — and the agent can look into bad news on its own. A watch reaches you on any browser you sign in from, without opening the chat first.
|
||||
|
||||
A sample of conversations is scored automatically so the agent keeps getting better; only the score and a one-line summary are kept, never your messages, data or code, and we can switch it off for your organization on request. The Docs button is gone from page headers — ask the agent instead, or open Documentation from Help & Feedback. Separately, a queue's wait times, peak depth, throughput and throttling can now be read from the API.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
The grouped "watch updates" notification now shows the total number of results waiting, instead of only the most recent batch's count.
|
||||
@@ -4,16 +4,20 @@ import type {
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { ChatActionsRow } from "./chat-layout";
|
||||
import { renderableActions } from "./view-actions";
|
||||
import { renderableActions, withoutWatchActions } from "./view-actions";
|
||||
|
||||
export function ActionsBlock({
|
||||
block,
|
||||
onIntent,
|
||||
dropWatch = false,
|
||||
}: {
|
||||
block: ActionsBlockPayload;
|
||||
onIntent?: (intent: AgentIntent) => void;
|
||||
/** Set when an investigation card in the same answer already offers the watch. */
|
||||
dropWatch?: boolean;
|
||||
}) {
|
||||
const renderable = renderableActions(block.actions);
|
||||
const actions = dropWatch ? withoutWatchActions(block.actions) : block.actions;
|
||||
const renderable = renderableActions(actions);
|
||||
if (!onIntent || renderable.length === 0) return null;
|
||||
return (
|
||||
<ChatActionsRow>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
|
||||
import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
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 { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
|
||||
import { agentDeepLinkParams, ASK_AI_SHORTCUT, askAiChannelTarget } from "./ask-ai-channels";
|
||||
@@ -18,18 +21,99 @@ import {
|
||||
readAgentFullscreen,
|
||||
writeAgentFullscreen,
|
||||
} from "./panel-layout";
|
||||
import { nextPendingTurnChatId } from "./pending-turn";
|
||||
import { nextVisibleChat } from "./unread-counts";
|
||||
import { createWakePendingCount, startWakePolling, wakesToToast } from "./wake-poll";
|
||||
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
|
||||
import {
|
||||
dismissWatchWakesSummaryToast,
|
||||
showWatchWakesSummaryToast,
|
||||
showWatchWakeToast,
|
||||
WAKE_TOAST_MAX_INDIVIDUAL,
|
||||
type WatchWake,
|
||||
} from "./WatchWakeToast";
|
||||
|
||||
const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes";
|
||||
|
||||
// Shorter than the poll interval, so a stuck request is dropped before the next tick.
|
||||
const UNREAD_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */
|
||||
export function DashboardAgent({
|
||||
children,
|
||||
hasAccess = false,
|
||||
promotedPrompt,
|
||||
/** From the page load: unread wakes waiting for this user, whatever this browser remembers. */
|
||||
initialUnreadWakes = 0,
|
||||
initialUnreadWork = 0,
|
||||
/** Also from the page load: a watch is running, so a wake can still arrive in this tab. */
|
||||
hasActiveWatches = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hasAccess?: boolean;
|
||||
promotedPrompt?: SuggestedPrompt;
|
||||
initialUnreadWakes?: number;
|
||||
/** Chats whose transcript moved on since their owner last looked. */
|
||||
initialUnreadWork?: number;
|
||||
hasActiveWatches?: boolean;
|
||||
}) {
|
||||
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);
|
||||
// Seeded from the page load, so the launcher dot is right before the first poll answers.
|
||||
const [unreadWakes, setUnreadWakes] = useState(initialUnreadWakes);
|
||||
// Work that finished behind a closed panel. Counted server-side on page load and refreshed
|
||||
// with the chat list; the wake poll doesn't carry it.
|
||||
const [unreadWork, setUnreadWork] = useState(initialUnreadWork);
|
||||
// A turn this tab started may finish after the panel closes; that is exactly the case the
|
||||
// dot exists for, so the poll has to be running when it lands.
|
||||
const [pendingTurnChatId, setPendingTurnChatId] = useState<string | null>(null);
|
||||
const handleTurnActivityChange = useCallback((chatId: string, active: boolean) => {
|
||||
setPendingTurnChatId((current) => nextPendingTurnChatId(current, { chatId, active }));
|
||||
}, []);
|
||||
const toastedWakes = useRef(new Set<string>());
|
||||
// The toast source is recent deliveries, not unread, so the dedupe must survive a reload.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TOASTED_WAKES_STORAGE_KEY);
|
||||
if (raw) for (const id of JSON.parse(raw) as string[]) toastedWakes.current.add(id);
|
||||
} catch {
|
||||
// Storage unavailable; the in-memory dedupe still applies.
|
||||
}
|
||||
}, []);
|
||||
const rememberToasted = useCallback((watchId: string) => {
|
||||
toastedWakes.current.add(watchId);
|
||||
try {
|
||||
// Newest ids only, so the key can't grow unbounded.
|
||||
window.localStorage.setItem(
|
||||
TOASTED_WAKES_STORAGE_KEY,
|
||||
JSON.stringify([...toastedWakes.current].slice(-50))
|
||||
);
|
||||
} catch {
|
||||
// Same as the read.
|
||||
}
|
||||
}, []);
|
||||
// A wake in the on-screen chat toasts but must not light the dot. Read by the poll callback,
|
||||
// which outlives the render that started it, so it has to be a ref.
|
||||
const visibleChat = useRef<string | null>(null);
|
||||
|
||||
// The count the still-visible grouped toast claims. Consecutive polls add to it so a
|
||||
// later batch grows the summary instead of overwriting it with only its own count;
|
||||
// reset when the user opens the panel, whichever route they took.
|
||||
const wakePending = useRef(createWakePendingCount());
|
||||
|
||||
// Switching environment re-runs the layout loader but does not remount it, so the seeds
|
||||
// above would keep the old environment's counts.
|
||||
const seededEnvironment = useRef(environment.id);
|
||||
useEffect(() => {
|
||||
if (seededEnvironment.current === environment.id) return;
|
||||
seededEnvironment.current = environment.id;
|
||||
setUnreadWakes(initialUnreadWakes);
|
||||
setUnreadWork(initialUnreadWork);
|
||||
}, [environment.id, initialUnreadWakes, initialUnreadWork]);
|
||||
// Read lazily so SSR always renders the side panel.
|
||||
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
|
||||
|
||||
@@ -55,23 +139,167 @@ export function DashboardAgent({
|
||||
const [requestedMessage, setRequestedMessage] = useState<
|
||||
{ text: string; seq: number } | undefined
|
||||
>(undefined);
|
||||
// `seq` so the same chat can be asked for twice.
|
||||
const [openChatRequest, setOpenChatRequest] = useState<
|
||||
{ chatId: string; seq: number } | undefined
|
||||
>(undefined);
|
||||
const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; seq: number } | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const setPanelOpen = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
// Pending requests must be dropped or a stale one re-applies on the next open.
|
||||
if (!next) {
|
||||
// The single entry point for opening the panel — every open route must go through it.
|
||||
// Opening acknowledges the wakes counted so far, and the visible summary goes with the
|
||||
// count it was claiming.
|
||||
const openPanel = useCallback(() => {
|
||||
wakePending.current.acknowledge();
|
||||
dismissWatchWakesSummaryToast();
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const setPanelOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) {
|
||||
openPanel();
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
// Pending requests must be dropped or a stale one re-applies on the next open.
|
||||
visibleChat.current = null;
|
||||
setFullscreen(false);
|
||||
writeAgentFullscreen(false);
|
||||
setRequestedMessage(undefined);
|
||||
}
|
||||
}, []);
|
||||
setOpenChatRequest(undefined);
|
||||
setWatchRequest(undefined);
|
||||
},
|
||||
[openPanel]
|
||||
);
|
||||
|
||||
const openWith = useCallback((text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
setOpen(true);
|
||||
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
|
||||
}, []);
|
||||
const openChat = useCallback(
|
||||
(chatId: string) => {
|
||||
openPanel();
|
||||
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
|
||||
},
|
||||
[openPanel]
|
||||
);
|
||||
|
||||
const openWith = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
openPanel();
|
||||
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
|
||||
},
|
||||
[openPanel]
|
||||
);
|
||||
|
||||
const openWithWatch = useCallback(
|
||||
(spec: WatchSpec) => {
|
||||
openPanel();
|
||||
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
|
||||
},
|
||||
[openPanel]
|
||||
);
|
||||
|
||||
// Nothing to be woken about means nothing to poll for. The page load's unread count and
|
||||
// active-watch flag are the ungated signals; the browser's own memory of a watch starts the
|
||||
// poll without a reload. Once any says yes this tab keeps polling, so a wake reaches a tab
|
||||
// that was open before the watch existed.
|
||||
const [watching, setWatching] = useState(false);
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
if (
|
||||
shouldPollWakeFeed({
|
||||
serverUnreadWakes: initialUnreadWakes,
|
||||
serverHasActiveWatches: hasActiveWatches,
|
||||
serverUnreadWork: initialUnreadWork,
|
||||
turnInFlight: pendingTurnChatId !== null,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
)
|
||||
setWatching(true);
|
||||
};
|
||||
sync();
|
||||
return subscribeWatchActivity(sync);
|
||||
}, [organization.id, initialUnreadWakes, hasActiveWatches, initialUnreadWork, pendingTurnChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess || !watching) return;
|
||||
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
// The chat on screen is being read, so the server leaves it out of the work count
|
||||
// rather than the client subtracting it back off afterwards.
|
||||
const onScreen = visibleChat.current;
|
||||
// Bounded, so one stuck request can't hold the poll's in-flight guard.
|
||||
const res = await fetch(
|
||||
`${actionPath}?unread=1${onScreen ? `&chatId=${encodeURIComponent(onScreen)}` : ""}`,
|
||||
{ signal: AbortSignal.timeout(UNREAD_REQUEST_TIMEOUT_MS) }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
unreadWakes?: number;
|
||||
unreadWork?: number;
|
||||
wakes?: WatchWake[];
|
||||
};
|
||||
if (cancelled) return;
|
||||
// The wakes list carries read ones too, so only unread ones are subtracted.
|
||||
const unreadInView = (data.wakes ?? []).filter(
|
||||
(wake) => wake.unread && wake.chatId === visibleChat.current
|
||||
).length;
|
||||
setUnreadWakes(Math.max(0, (data.unreadWakes ?? 0) - unreadInView));
|
||||
setUnreadWork(Math.max(0, data.unreadWork ?? 0));
|
||||
|
||||
const fresh = wakesToToast(data.wakes, toastedWakes.current);
|
||||
for (const wake of fresh) rememberToasted(wake.watchId);
|
||||
|
||||
if (fresh.length > 0) {
|
||||
const plan = wakePending.current.plan(fresh, WAKE_TOAST_MAX_INDIVIDUAL);
|
||||
if (plan.mode === "summary") {
|
||||
showWatchWakesSummaryToast(plan.count, () => setPanelOpen(true));
|
||||
} else {
|
||||
for (const wake of [...plan.wakes].reverse()) {
|
||||
showWatchWakeToast(wake, openChat);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try again next tick.
|
||||
}
|
||||
};
|
||||
|
||||
const stop = startWakePolling({
|
||||
load,
|
||||
isHidden: () => document.hidden,
|
||||
onVisibilityChange: (listener) => {
|
||||
document.addEventListener("visibilitychange", listener);
|
||||
return () => document.removeEventListener("visibilitychange", listener);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop();
|
||||
};
|
||||
}, [hasAccess, watching, actionPath, setPanelOpen, openChat]);
|
||||
|
||||
// Zeroes the wake dot right away; the poll restores the truth if another chat has one. The
|
||||
// work count is not touched here: the panel derives it from the chat list.
|
||||
const markChatRead = useCallback(
|
||||
async (chatId: string, options: { leaving: boolean }) => {
|
||||
visibleChat.current = nextVisibleChat(chatId, options);
|
||||
setUnreadWakes(0);
|
||||
const body = new FormData();
|
||||
body.set("intent", "read");
|
||||
body.set("chatId", chatId);
|
||||
try {
|
||||
await fetch(actionPath, { method: "POST", body });
|
||||
} catch {
|
||||
// Catches up on the next open.
|
||||
}
|
||||
},
|
||||
[actionPath]
|
||||
);
|
||||
|
||||
// ⌘J is contextual: closed opens the panel, open starts a new chat. It never closes.
|
||||
useShortcutKeys({
|
||||
@@ -107,8 +335,8 @@ export function DashboardAgent({
|
||||
});
|
||||
|
||||
const context = useMemo(
|
||||
() => ({ open, setOpen: setPanelOpen, openWith }),
|
||||
[open, setPanelOpen, openWith]
|
||||
() => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes, unreadWork }),
|
||||
[open, setPanelOpen, openWith, openWithWatch, unreadWakes, unreadWork]
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
@@ -137,8 +365,14 @@ export function DashboardAgent({
|
||||
<DashboardAgentPanel
|
||||
onClose={() => setPanelOpen(false)}
|
||||
requestedMessage={requestedMessage}
|
||||
openChatRequest={openChatRequest}
|
||||
watchRequest={watchRequest}
|
||||
newChatSeq={newChatSeq}
|
||||
promotedPrompt={promotedPrompt}
|
||||
onChatRead={markChatRead}
|
||||
// The panel's own count, off the chat list it has already marked read.
|
||||
onUnreadWorkChange={setUnreadWork}
|
||||
onTurnActivityChange={handleTurnActivityChange}
|
||||
isFullscreen={fullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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 { useNavigate } from "@remix-run/react";
|
||||
import {
|
||||
isWatchRequestMessageId,
|
||||
type AgentIntent,
|
||||
type SuggestedPrompt,
|
||||
type WatchSpec,
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
import { useLocation, useNavigate } from "@remix-run/react";
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
@@ -14,7 +19,7 @@ import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessa
|
||||
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
|
||||
import { createTranscriptOrder, orderTranscript } from "./message-order";
|
||||
import { navigateDestination } from "./navigate-target";
|
||||
import { pendingNavigateIntents } from "./pending-intents";
|
||||
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
|
||||
import type { AgentPageContext } from "./page-context-types";
|
||||
import { retryAction } from "./retry-action";
|
||||
import {
|
||||
@@ -22,8 +27,12 @@ import {
|
||||
pollSettledTranscript,
|
||||
transcriptLooksUnfinished,
|
||||
} from "./settled-transcript";
|
||||
import { takeNavigateIntent } from "./turn-navigation";
|
||||
import { sendRequestOutcome } from "./send-request";
|
||||
import { teardownCancelsTurn, unmountTeardown } from "./turn-teardown";
|
||||
import { useAgentMessageQuota } from "./useAgentMessageQuota";
|
||||
import { useTriggerUriResolver } from "./useTriggerUriResolver";
|
||||
import { WatchChips, type WatchChip } from "./WatchChips";
|
||||
|
||||
// Resuming with `lastEventId` stops the `.out` stream replaying the previous turn.
|
||||
export type DashboardAgentSession = {
|
||||
@@ -54,9 +63,14 @@ export function DashboardAgentChat({
|
||||
currentPage,
|
||||
pendingFirstMessage,
|
||||
streaming,
|
||||
prefill,
|
||||
sendRequest,
|
||||
promotedPrompt,
|
||||
watches,
|
||||
pagePaths,
|
||||
watchCard,
|
||||
appendedMessages,
|
||||
onWatchIntent,
|
||||
onCancelWatch,
|
||||
onTurnSettled,
|
||||
onActivityChange,
|
||||
}: {
|
||||
@@ -73,23 +87,29 @@ export function DashboardAgentChat({
|
||||
// Undefined for head-started and resumed chats.
|
||||
pendingFirstMessage?: string;
|
||||
streaming?: boolean;
|
||||
// `seq` makes each request distinct so the same text can be sent twice.
|
||||
prefill?: { text: string; seq: number };
|
||||
// A prompt the user asked for by clicking. `seq` makes each request distinct so the same
|
||||
// text can be sent twice.
|
||||
sendRequest?: { text: string; seq: number };
|
||||
promotedPrompt?: SuggestedPrompt;
|
||||
watches: WatchChip[];
|
||||
pagePaths?: Record<string, string>;
|
||||
watchCard?: React.ReactNode;
|
||||
appendedMessages?: { messages: UIMessage[]; seq: number };
|
||||
/** Nothing is persisted until the user submits the card. */
|
||||
onWatchIntent?: (spec: WatchSpec) => void;
|
||||
onCancelWatch: (watchId: string) => void;
|
||||
onTurnSettled: () => void;
|
||||
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const toast = useToast();
|
||||
|
||||
const prefilledSeq = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!prefill || prefilledSeq.current === prefill.seq) return;
|
||||
prefilledSeq.current = prefill.seq;
|
||||
setInput(prefill.text);
|
||||
}, [prefill]);
|
||||
// The path this chat last rendered on. React never unmounts on a page teardown, so an
|
||||
// unmount whose live URL has moved is the router having navigated out from under it.
|
||||
const renderedPathRef = useRef(location.pathname);
|
||||
renderedPathRef.current = location.pathname;
|
||||
|
||||
const transport = useTriggerChatTransport<typeof dashboardAgent>({
|
||||
task: "dashboard-agent",
|
||||
@@ -174,10 +194,31 @@ export function DashboardAgentChat({
|
||||
const activity: TurnActivity | null =
|
||||
status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;
|
||||
|
||||
// Once per `seq`: the append is already persisted, so a replay would duplicate it.
|
||||
// Ids are stable, so anything already in the transcript is skipped.
|
||||
const appendedSeq = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!appendedMessages || appendedSeq.current === appendedMessages.seq) return;
|
||||
appendedSeq.current = appendedMessages.seq;
|
||||
setMessages((current) => {
|
||||
const missing = appendedMessages.messages.filter(
|
||||
(message) => !current.some((existing) => existing.id === message.id)
|
||||
);
|
||||
return missing.length === 0 ? current : [...current, ...missing];
|
||||
});
|
||||
}, [appendedMessages, setMessages]);
|
||||
|
||||
// Where this tab asked for the running turn, stamped only where a turn is actually started
|
||||
// here. A turn this tab resumed leaves it null, which is what tells `takeNavigateIntent` the
|
||||
// tab cannot claim the user is still on the page that asked. Never cleared on settle: the
|
||||
// navigate intent can be committed alongside the status going ready.
|
||||
const turnStartedPathRef = useRef<string | null>(null);
|
||||
|
||||
const sentFirst = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pendingFirstMessage && !sentFirst.current) {
|
||||
sentFirst.current = true;
|
||||
turnStartedPathRef.current = renderedPathRef.current;
|
||||
void sendMessage({ text: pendingFirstMessage });
|
||||
}
|
||||
}, [pendingFirstMessage, sendMessage]);
|
||||
@@ -188,15 +229,36 @@ export function DashboardAgentChat({
|
||||
// Suggested prompts and card actions bypass the composer, so the cap is enforced here too.
|
||||
if (!trimmed || isStreaming || atMessageCap) return;
|
||||
setInput("");
|
||||
turnStartedPathRef.current = renderedPathRef.current;
|
||||
void sendMessage({ text: trimmed });
|
||||
},
|
||||
[isStreaming, atMessageCap, sendMessage]
|
||||
);
|
||||
|
||||
// The panel only sends when the chat can take it, so this never lands mid-turn. The cap it
|
||||
// cannot see is why the request is held rather than consumed on sight.
|
||||
const sentRequestSeq = useRef<number | undefined>(undefined);
|
||||
const canSend = !isStreaming && !atMessageCap;
|
||||
useEffect(() => {
|
||||
if (!sendRequest) return;
|
||||
const outcome = sendRequestOutcome({
|
||||
requestSeq: sendRequest.seq,
|
||||
consumedSeq: sentRequestSeq.current,
|
||||
canSend,
|
||||
});
|
||||
if (outcome !== "send") return;
|
||||
sentRequestSeq.current = sendRequest.seq;
|
||||
submit(sendRequest.text);
|
||||
}, [sendRequest, submit, canSend]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const action = retryAction(messages);
|
||||
// A watch's consent record is a user message nobody typed, so retry never treats it as one.
|
||||
const action = retryAction(
|
||||
messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id)))
|
||||
);
|
||||
if (!action) return;
|
||||
clearError();
|
||||
turnStartedPathRef.current = renderedPathRef.current;
|
||||
if (action.kind === "regenerate") {
|
||||
void regenerate();
|
||||
return;
|
||||
@@ -241,6 +303,9 @@ export function DashboardAgentChat({
|
||||
case "ask":
|
||||
submit(intent.prompt);
|
||||
return;
|
||||
case "watch":
|
||||
onWatchIntent?.(intent.spec);
|
||||
return;
|
||||
case "navigate":
|
||||
void goTo(intent);
|
||||
return;
|
||||
@@ -248,7 +313,7 @@ export function DashboardAgentChat({
|
||||
console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
|
||||
}
|
||||
},
|
||||
[submit, goTo]
|
||||
[submit, goTo, onWatchIntent]
|
||||
);
|
||||
|
||||
// Seeded from the loaded transcript before first render, so history never re-navigates.
|
||||
@@ -258,16 +323,43 @@ export function DashboardAgentChat({
|
||||
pendingNavigateIntents(initialMessages, navigatedRef.current);
|
||||
}
|
||||
useEffect(() => {
|
||||
const pending = pendingNavigateIntents(messages, navigatedRef.current!);
|
||||
const target = pending.at(-1);
|
||||
const target = takeNavigateIntent({
|
||||
messages,
|
||||
handled: navigatedRef.current!,
|
||||
startedPath: turnStartedPathRef.current,
|
||||
currentPath: renderedPathRef.current,
|
||||
});
|
||||
if (target) void goTo(target);
|
||||
}, [messages, goTo]);
|
||||
|
||||
const watchProposedRef = useRef<Set<string> | null>(null);
|
||||
if (watchProposedRef.current === null) {
|
||||
watchProposedRef.current = new Set();
|
||||
pendingWatchIntents(initialMessages, watchProposedRef.current);
|
||||
}
|
||||
useEffect(() => {
|
||||
const pending = pendingWatchIntents(messages, watchProposedRef.current!);
|
||||
const proposed = pending.at(-1);
|
||||
if (proposed) onWatchIntent?.(proposed.spec);
|
||||
}, [messages, onWatchIntent]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
transport.stopGeneration(chatId);
|
||||
aiStop();
|
||||
}, [transport, chatId, aiStop]);
|
||||
|
||||
const teardownRef = useRef<() => void>(() => {});
|
||||
teardownRef.current = () => {
|
||||
if (status !== "streaming" && status !== "submitted") return;
|
||||
const reason = unmountTeardown({
|
||||
renderedPath: renderedPathRef.current,
|
||||
livePath: window.location.pathname,
|
||||
});
|
||||
if (!teardownCancelsTurn(reason)) return;
|
||||
stop();
|
||||
};
|
||||
useEffect(() => () => teardownRef.current(), []);
|
||||
|
||||
// Read by the settle effect, which must not re-run when the transcript changes.
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
@@ -298,6 +390,10 @@ export function DashboardAgentChat({
|
||||
|
||||
return (
|
||||
<>
|
||||
<WatchChips
|
||||
watches={watches.filter((watch) => watch.status === "active")}
|
||||
onCancel={onCancelWatch}
|
||||
/>
|
||||
{messages.length === 0 && !pendingFirstMessage ? (
|
||||
<DashboardAgentHero
|
||||
onSelect={submit}
|
||||
@@ -313,9 +409,11 @@ export function DashboardAgentChat({
|
||||
onDismissError={clearError}
|
||||
onIntent={handleIntent}
|
||||
pagePaths={pagePaths}
|
||||
watches={watches}
|
||||
resolveUri={resolveUri}
|
||||
/>
|
||||
)}
|
||||
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
|
||||
{quota.kind === "reached" ? (
|
||||
<AgentUpgradeBlock
|
||||
limit={quota.limit}
|
||||
@@ -335,7 +433,7 @@ export function DashboardAgentChat({
|
||||
onSubmit={() => submit(input)}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
focusKey={prefill?.seq}
|
||||
focusKey={sendRequest?.seq}
|
||||
context={
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function DashboardAgentDraft({
|
||||
currentPage,
|
||||
pageContext,
|
||||
promotedPrompt,
|
||||
watchCard,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
projectSlug: string;
|
||||
@@ -22,6 +23,7 @@ export function DashboardAgentDraft({
|
||||
currentPage: string;
|
||||
pageContext?: AgentPageContext;
|
||||
promotedPrompt?: SuggestedPrompt;
|
||||
watchCard?: React.ReactNode;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
@@ -56,6 +58,7 @@ export function DashboardAgentDraft({
|
||||
promoted={promotedPrompt}
|
||||
composer={
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{watchCard}
|
||||
<DashboardAgentComposer
|
||||
layout="hero"
|
||||
value={input}
|
||||
@@ -63,7 +66,7 @@ export function DashboardAgentDraft({
|
||||
onSubmit={() => submit(input)}
|
||||
onStop={() => {}}
|
||||
isStreaming={false}
|
||||
placeholderSuggestion={placeholderSuggestion}
|
||||
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
|
||||
context={
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
|
||||
@@ -6,7 +6,11 @@ import { Button } from "~/components/primitives/Buttons";
|
||||
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import type { Shortcut } from "~/hooks/useShortcutKeys";
|
||||
import { DashboardAgentHistoryMenu, type DashboardAgentChat } from "./DashboardAgentHistory";
|
||||
import {
|
||||
DashboardAgentDeleteChatDialog,
|
||||
DashboardAgentHistoryMenu,
|
||||
type DashboardAgentChat,
|
||||
} from "./DashboardAgentHistory";
|
||||
import { chatHistoryTriggerLabel } from "./header-labels";
|
||||
|
||||
// Display only. The key is registered once, in `DashboardAgent`; registering it
|
||||
@@ -45,6 +49,7 @@ export function DashboardAgentHeader({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [isHistoryOpen, setHistoryOpen] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<DashboardAgentChat | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-grid-bright pl-1 pr-1.5">
|
||||
@@ -77,11 +82,20 @@ export function DashboardAgentHeader({
|
||||
setHistoryOpen(false);
|
||||
onSelectChat(chatId);
|
||||
}}
|
||||
onDelete={onDeleteChat}
|
||||
onRequestDelete={(chat) => {
|
||||
setHistoryOpen(false);
|
||||
setPendingDelete(chat);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DashboardAgentDeleteChatDialog
|
||||
chat={pendingDelete}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
onConfirm={onDeleteChat}
|
||||
/>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{showNewChat && (
|
||||
<Button
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import { MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
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;
|
||||
watches?: WatchChip[];
|
||||
hasUnreadWake?: boolean;
|
||||
/** The chat answered, settled a card or woke while it was closed. */
|
||||
hasUnreadWork?: boolean;
|
||||
hasActiveWatch?: boolean;
|
||||
hasOpenInvestigation?: boolean;
|
||||
};
|
||||
|
||||
type ChatProcess = "thinking" | "investigating";
|
||||
type ChatProcess = "thinking" | "investigating" | "watching";
|
||||
|
||||
const PROCESS_LABELS: Record<ChatProcess, string> = {
|
||||
thinking: "Agent is thinking",
|
||||
investigating: "Investigation in progress",
|
||||
watching: "Watch active",
|
||||
};
|
||||
|
||||
function chatProcess(chat: DashboardAgentChat, isThinking: boolean): ChatProcess | null {
|
||||
if (isThinking) return "thinking";
|
||||
if (chat.hasOpenInvestigation) return "investigating";
|
||||
if (chat.hasActiveWatch) return "watching";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,6 +50,16 @@ function ProcessIcon({ process }: { process: ChatProcess }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A wake is unread work too, so one predicate answers for both. */
|
||||
export function chatIsUnread(chat: DashboardAgentChat): boolean {
|
||||
return (chat.hasUnreadWake ?? false) || (chat.hasUnreadWork ?? false);
|
||||
}
|
||||
|
||||
// Must stay a stable sort on one key: everything else keeps the server's order.
|
||||
function unreadFirst(chats: DashboardAgentChat[]): DashboardAgentChat[] {
|
||||
return [...chats].sort((a, b) => Number(chatIsUnread(b)) - Number(chatIsUnread(a)));
|
||||
}
|
||||
|
||||
// Weeks are the coarsest unit: months render as "1.8mo" for eight weeks.
|
||||
const AGE_UNITS = ["w", "d", "h", "m"] as const;
|
||||
|
||||
@@ -64,86 +81,95 @@ export function DashboardAgentHistoryMenu({
|
||||
currentChatId,
|
||||
thinkingChatId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
chats: DashboardAgentChat[];
|
||||
currentChatId: string;
|
||||
thinkingChatId?: string | null;
|
||||
onSelect: (chatId: string) => void;
|
||||
onDelete: (chatId: string) => void;
|
||||
onRequestDelete: (chat: DashboardAgentChat) => void;
|
||||
}) {
|
||||
const [pendingDelete, setPendingDelete] = useState<DashboardAgentChat | null>(null);
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-h-80 overflow-y-auto p-1.5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{chats.length === 0 ? (
|
||||
<Paragraph variant="small" className="p-1.5 text-text-dimmed">
|
||||
No previous chats yet.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<AgentList>
|
||||
{chats.map((chat) => {
|
||||
const process = chatProcess(chat, chat.id === thinkingChatId);
|
||||
const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined;
|
||||
return (
|
||||
<AgentListRow
|
||||
key={chat.id}
|
||||
label={chat.title}
|
||||
status={process ? <ProcessIcon process={process} /> : null}
|
||||
meta={age}
|
||||
variant={chat.id === currentChatId ? "selected" : "default"}
|
||||
onSelect={() => onSelect(chat.id)}
|
||||
action={
|
||||
<AgentListRowAction
|
||||
icon={TrashIcon}
|
||||
label={`Delete chat: ${chat.title}`}
|
||||
onClick={() => setPendingDelete(chat)}
|
||||
danger
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AgentList>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>Delete this chat?</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
"{pendingDelete?.title}" and everything in it will be deleted. This can't be undone.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
onClick={() => {
|
||||
if (pendingDelete) onDelete(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}}
|
||||
>
|
||||
Delete chat
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<Button variant="tertiary/medium" onClick={() => setPendingDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<div className="max-h-80 overflow-y-auto p-1.5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{chats.length === 0 ? (
|
||||
<Paragraph variant="small" className="p-1.5 text-text-dimmed">
|
||||
No previous chats yet.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<AgentList>
|
||||
{unreadFirst(chats).map((chat) => {
|
||||
const process = chatProcess(chat, chat.id === thinkingChatId);
|
||||
const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined;
|
||||
return (
|
||||
<AgentListRow
|
||||
key={chat.id}
|
||||
label={chat.title}
|
||||
unread={chatIsUnread(chat)}
|
||||
status={process ? <ProcessIcon process={process} /> : null}
|
||||
meta={age}
|
||||
variant={chat.id === currentChatId ? "selected" : "default"}
|
||||
onSelect={() => onSelect(chat.id)}
|
||||
action={
|
||||
<AgentListRowAction
|
||||
icon={TrashIcon}
|
||||
label={`Delete chat: ${chat.title}`}
|
||||
onClick={() => onRequestDelete(chat)}
|
||||
danger
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AgentList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Rendered outside the history popover: inside it, focus moving to the dialog dismisses the
|
||||
// popover, which unmounts the dialog before it can be answered.
|
||||
export function DashboardAgentDeleteChatDialog({
|
||||
chat,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
chat: DashboardAgentChat | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (chatId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={chat !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>Delete this chat?</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
"{chat?.title}" and everything in it will be deleted. This can't be undone.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
onClick={() => {
|
||||
if (chat) onConfirm(chat.id);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Delete chat
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<Button variant="tertiary/medium" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,17 @@ import {
|
||||
ChatText,
|
||||
ChatTranscript,
|
||||
ChatTurn,
|
||||
ChatWakeSlot,
|
||||
} from "./chat-layout";
|
||||
import { reuseWinners } from "./investigation-winners";
|
||||
import { stripModelImages } from "./model-markdown";
|
||||
import { reportBlockFromToolPart } from "./report-block-adapter";
|
||||
import { shouldShowLiveTurnError } from "./turn-error";
|
||||
import type { ResolvedUri } from "./ReportView";
|
||||
import { answerContinuesAfter } from "./view-actions";
|
||||
import { answerContinuesAfter, turnAlreadyOffersWatch, turnProposesWatch } from "./view-actions";
|
||||
import { latestRevisionBlocks } from "./view-blocks";
|
||||
import { ViewBlocks } from "./view-catalog";
|
||||
import { findWakeWatch, WakeBanner, wakeRefFromMessageId, type WakeWatch } from "./WakeBanner";
|
||||
|
||||
export type { TurnActivity };
|
||||
|
||||
@@ -36,6 +39,8 @@ export type DashboardAgentMessagesProps = {
|
||||
onIntent?: (intent: AgentIntent) => void;
|
||||
resolveUri?: (uri: string) => ResolvedUri | null;
|
||||
pagePaths?: Record<string, string>;
|
||||
/** Optional: without it a wake banner falls back to kind-agnostic wording. */
|
||||
watches?: WakeWatch[];
|
||||
};
|
||||
|
||||
// Cached so a stripped message keeps its identity across renders and memoization holds:
|
||||
@@ -239,12 +244,14 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
|
||||
onIntent,
|
||||
resolveUri,
|
||||
pagePaths,
|
||||
watches,
|
||||
investigationWinners,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
onIntent?: (intent: AgentIntent) => void;
|
||||
resolveUri?: (uri: string) => ResolvedUri | null;
|
||||
pagePaths?: Record<string, string>;
|
||||
watches?: WakeWatch[];
|
||||
/** See {@link winningInvestigationOccurrences}. */
|
||||
investigationWinners?: Map<string, string>;
|
||||
}) {
|
||||
@@ -259,18 +266,31 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
|
||||
const parts = message.parts ?? [];
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
// Null for a part that renders no view at all; an empty array for one whose blocks were
|
||||
// all superseded. Both skip the part, only the first falls through to the other renderers.
|
||||
const blocksByPart = parts.map((part, i) => {
|
||||
const raw = blocksFor(part);
|
||||
return raw
|
||||
? withoutSupersededInvestigations(raw, `${message.id}:${i}`, investigationWinners)
|
||||
: null;
|
||||
});
|
||||
// One answer for the whole turn: two `render_view` parts each deciding for themselves
|
||||
// would show the watch button twice. `ViewBlocks` collapses revisions the same way.
|
||||
const watchOfferedInTurn =
|
||||
turnProposesWatch(parts as never) ||
|
||||
turnAlreadyOffersWatch(
|
||||
blocksByPart
|
||||
.filter((blocks): blocks is unknown[] => blocks !== null)
|
||||
.map((blocks) => latestRevisionBlocks(blocks as never))
|
||||
);
|
||||
|
||||
const body: React.ReactNode[] = [];
|
||||
const actionRows: React.ReactNode[] = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]!;
|
||||
|
||||
const rawBlocks = blocksFor(part);
|
||||
if (rawBlocks) {
|
||||
const blocks = withoutSupersededInvestigations(
|
||||
rawBlocks,
|
||||
`${message.id}:${i}`,
|
||||
investigationWinners
|
||||
);
|
||||
const blocks = blocksByPart[i];
|
||||
if (blocks) {
|
||||
// `answered` stays keyed on the emission index: the reorder is display only.
|
||||
const slot = (list: unknown[], key: string) => (
|
||||
<ChatCardSlot key={key}>
|
||||
@@ -280,6 +300,7 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
|
||||
resolveUri={resolveUri}
|
||||
pagePaths={pagePaths}
|
||||
answered={answerContinuesAfter(parts as never, i)}
|
||||
watchOfferedInTurn={watchOfferedInTurn}
|
||||
/>
|
||||
</ChatCardSlot>
|
||||
);
|
||||
@@ -306,6 +327,22 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
|
||||
body.push(renderDashboardPart(part, i, resolveUri));
|
||||
}
|
||||
|
||||
const wake = wakeRefFromMessageId(message.id);
|
||||
if (wake) {
|
||||
return (
|
||||
<ChatTurn>
|
||||
<ChatWakeSlot
|
||||
banner={
|
||||
<WakeBanner outcome={wake.outcome} watch={findWakeWatch(watches, wake.watchId)} />
|
||||
}
|
||||
>
|
||||
{body}
|
||||
{actionRows}
|
||||
</ChatWakeSlot>
|
||||
</ChatTurn>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatTurn>
|
||||
{body}
|
||||
@@ -323,6 +360,7 @@ export function DashboardAgentTurns({
|
||||
onIntent,
|
||||
resolveUri,
|
||||
pagePaths,
|
||||
watches,
|
||||
}: DashboardAgentMessagesProps) {
|
||||
// Must be the exact parts the turns render: the winners map keys by part index.
|
||||
const stripped = useMemo(() => messages.map(stripStepParts), [messages]);
|
||||
@@ -344,6 +382,7 @@ export function DashboardAgentTurns({
|
||||
onIntent={onIntent}
|
||||
resolveUri={resolveUri}
|
||||
pagePaths={pagePaths}
|
||||
watches={watches}
|
||||
investigationWinners={investigationWinners}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { AgentSpinner } from "~/components/primitives/Spinner";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
import { useAgentPageContext } from "~/hooks/useAgentPageContext";
|
||||
@@ -24,13 +24,25 @@ import {
|
||||
writeLastChat,
|
||||
} from "./last-chat-storage";
|
||||
import { DashboardAgentDraft } from "./DashboardAgentDraft";
|
||||
import { WatchCard } from "./WatchCard";
|
||||
import { watchDraftFor } from "./watch-card";
|
||||
import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state";
|
||||
import { forgetWatchActivity, rememberWatchActivity } from "./watch-activity";
|
||||
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, WatchSpec } from "@internal/dashboard-agent-contracts";
|
||||
import { resolveOpenedChat, type OpenedChatResponse } from "./opened-chat";
|
||||
import type { AgentPageContext } from "./page-context-types";
|
||||
import { agentPageLabel } from "./page-label";
|
||||
import { explicitPromptTarget } from "./explicit-prompt";
|
||||
import { escapeClosesPanel } from "./panel-escape";
|
||||
import {
|
||||
markChatListRead,
|
||||
nextVisibleChat,
|
||||
settleReadChats,
|
||||
unreadWorkCount,
|
||||
} from "./unread-counts";
|
||||
import { AgentPanelColumn } from "./panel-layout";
|
||||
import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker";
|
||||
import { concurrencyPath } from "~/utils/pathBuilder";
|
||||
@@ -58,8 +70,13 @@ type ActiveChat = {
|
||||
export function DashboardAgentPanel({
|
||||
onClose,
|
||||
requestedMessage,
|
||||
openChatRequest,
|
||||
newChatSeq,
|
||||
promotedPrompt,
|
||||
watchRequest,
|
||||
onChatRead,
|
||||
onUnreadWorkChange,
|
||||
onTurnActivityChange,
|
||||
isFullscreen = false,
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
@@ -68,8 +85,15 @@ export function DashboardAgentPanel({
|
||||
onToggleFullscreen?: () => void;
|
||||
// Every `seq` below distinguishes repeat requests with identical contents.
|
||||
requestedMessage?: { text: string; seq: number };
|
||||
openChatRequest?: { chatId: string; seq: number };
|
||||
newChatSeq?: number;
|
||||
promotedPrompt?: SuggestedPrompt;
|
||||
watchRequest?: { spec: WatchSpec; seq: number };
|
||||
onChatRead?: (chatId: string, options: { leaving: boolean }) => void;
|
||||
/** How many chats still hold work their owner hasn't seen. */
|
||||
onUnreadWorkChange?: (count: number) => void;
|
||||
/** Whether a turn is running in a chat, so a closed panel still knows to expect an answer. */
|
||||
onTurnActivityChange?: (chatId: string, active: boolean) => void;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
@@ -83,7 +107,12 @@ export function DashboardAgentPanel({
|
||||
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
|
||||
const storageKey = lastChatStorageKey(organization.id);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
// Declared before the chat plumbing: changing chat dispatches into it.
|
||||
const [watchCard, dispatchWatchCard] = useReducer(watchCardReducer, NO_WATCH_CARD);
|
||||
const [chats, setChats] = useState<DashboardAgentChatListItem[]>([]);
|
||||
// Until the list has arrived, the page load's server count is the better answer.
|
||||
const [chatsLoaded, setChatsLoaded] = useState(false);
|
||||
const [active, setActive] = useState<ActiveChat | null>(null);
|
||||
// Starts true so an `openWith` request waits for the restore instead of racing it.
|
||||
const [loading, setLoading] = useState(
|
||||
@@ -113,9 +142,20 @@ export function DashboardAgentPanel({
|
||||
);
|
||||
|
||||
const [thinkingChatId, setThinkingChatId] = useState<string | null>(null);
|
||||
const handleActivityChange = useCallback((chatId: string, activity: TurnActivity | null) => {
|
||||
setThinkingChatId((previous) => markerAfterActivity(previous, chatId, activity));
|
||||
}, []);
|
||||
const handleActivityChange = useCallback(
|
||||
(chatId: string, activity: TurnActivity | null) => {
|
||||
setThinkingChatId((previous) => markerAfterActivity(previous, chatId, activity));
|
||||
onTurnActivityChange?.(chatId, activity !== null);
|
||||
},
|
||||
[onTurnActivityChange]
|
||||
);
|
||||
|
||||
// The read POST and its reload can land out of order, so mask the next list.
|
||||
const justRead = useRef<Set<string>>(new Set());
|
||||
|
||||
// Read when the response lands, not when it was requested, so a chat switched to mid-flight
|
||||
// is the one the list settles against.
|
||||
const visibleChatId = useRef<string | null>(null);
|
||||
|
||||
// Ordering-safe: if the new chat has not reported yet, its own report re-sets the marker.
|
||||
useEffect(() => {
|
||||
@@ -129,21 +169,46 @@ 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();
|
||||
const chats = data.chats ?? [];
|
||||
// Reloaded after every turn and after a watch is created, so this is where the browser
|
||||
// learns whether the wake feed is worth polling.
|
||||
const pending = chats.some((chat) => chat.hasActiveWatch || chat.hasUnreadWake);
|
||||
if (pending) rememberWatchActivity(organization.id);
|
||||
else forgetWatchActivity(organization.id);
|
||||
const settled = settleReadChats(chats, read, visibleChatId.current);
|
||||
setChats(settled);
|
||||
setChatsLoaded(true);
|
||||
} 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.");
|
||||
}
|
||||
}),
|
||||
[actionPath, toast]
|
||||
[actionPath, organization.id, toast]
|
||||
);
|
||||
|
||||
// Bumped on each open so a slower earlier open can't overwrite a newer one.
|
||||
const openChatRequestSeq = useRef(0);
|
||||
|
||||
// Bound to its chat, which remounts with a fresh guard ref on every switch.
|
||||
const [sendRequest, setSendRequest] = useState<
|
||||
{ text: string; seq: number; chatId: string } | undefined
|
||||
>(undefined);
|
||||
|
||||
// The one way the panel changes chat: it invalidates any in-flight open and abandons a
|
||||
// half-configured watch card, which would otherwise be submitted against the new chat.
|
||||
const claimChatSlot = useCallback(() => {
|
||||
dispatchWatchCard({ type: "chat-changed" });
|
||||
// A request belongs to the chat it was made in: the remounting chat has a fresh guard ref,
|
||||
// so a kept request would be sent a second time.
|
||||
setSendRequest(undefined);
|
||||
return ++openChatRequestSeq.current;
|
||||
}, []);
|
||||
|
||||
const openChat = useCallback(
|
||||
async (id: string) => {
|
||||
const seq = ++openChatRequestSeq.current;
|
||||
const seq = claimChatSlot();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
|
||||
@@ -172,12 +237,12 @@ export function DashboardAgentPanel({
|
||||
if (seq === openChatRequestSeq.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[actionPath, organization.id, storageKey, toast]
|
||||
[actionPath, claimChatSlot, organization.id, storageKey, toast]
|
||||
);
|
||||
|
||||
const createChat = useCallback(
|
||||
async (text: string) => {
|
||||
const seq = ++openChatRequestSeq.current;
|
||||
const seq = claimChatSlot();
|
||||
setLoading(true);
|
||||
try {
|
||||
const userMessage: UIMessage = {
|
||||
@@ -219,7 +284,7 @@ export function DashboardAgentPanel({
|
||||
if (seq === openChatRequestSeq.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[actionPath, clientData, organization.id, toast]
|
||||
[actionPath, claimChatSlot, clientData, organization.id, toast]
|
||||
);
|
||||
|
||||
const restored = useRef(false);
|
||||
@@ -242,40 +307,175 @@ export function DashboardAgentPanel({
|
||||
useEffect(() => {
|
||||
if (panelOrg.current === organization.id) return;
|
||||
panelOrg.current = organization.id;
|
||||
openChatRequestSeq.current += 1;
|
||||
claimChatSlot();
|
||||
setActive(null);
|
||||
setLoading(false);
|
||||
setChats([]);
|
||||
setChatsLoaded(false);
|
||||
void loadHistory();
|
||||
}, [organization.id, loadHistory]);
|
||||
}, [organization.id, claimChatSlot, loadHistory]);
|
||||
|
||||
const handledOpenChatSeq = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!openChatRequest || handledOpenChatSeq.current === openChatRequest.seq) return;
|
||||
handledOpenChatSeq.current = openChatRequest.seq;
|
||||
// Reloading the visible transcript 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPersistLastChat(active, organization.id)) return;
|
||||
writeLastChat(storageKey, { chatId: active.chatId, path: location.pathname });
|
||||
}, [active, organization.id, storageKey, location.pathname]);
|
||||
|
||||
// Bound to its chat, which remounts with a fresh guard ref on every switch.
|
||||
const [prefill, setPrefill] = useState<{ text: string; seq: number; chatId: string } | undefined>(
|
||||
undefined
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!active?.chatId) return;
|
||||
const chatId = active.chatId;
|
||||
onChatRead?.(chatId, { leaving: false });
|
||||
visibleChatId.current = nextVisibleChat(chatId, { leaving: false });
|
||||
justRead.current.add(chatId);
|
||||
setChats((previous) => markChatListRead(previous, chatId));
|
||||
// Read again on the way out: a wake can land while the chat is open.
|
||||
return () => {
|
||||
onChatRead?.(chatId, { leaving: true });
|
||||
visibleChatId.current = nextVisibleChat(chatId, { leaving: true });
|
||||
justRead.current.add(chatId);
|
||||
setChats((previous) => markChatListRead(previous, chatId));
|
||||
};
|
||||
}, [active?.chatId, onChatRead]);
|
||||
|
||||
// The one source for the dot's work count: nudging it per open double-subtracts.
|
||||
useEffect(() => {
|
||||
if (!chatsLoaded) return;
|
||||
onUnreadWorkChange?.(unreadWorkCount(chats, active?.chatId));
|
||||
}, [chats, chatsLoaded, active?.chatId, onUnreadWorkChange]);
|
||||
|
||||
const handledRequestSeq = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!requestedMessage || loading) return;
|
||||
if (handledRequestSeq.current === requestedMessage.seq) return;
|
||||
if (!requestedMessage || handledRequestSeq.current === requestedMessage.seq) return;
|
||||
const target = explicitPromptTarget({
|
||||
chat: loading ? "opening" : active ? "open" : "none",
|
||||
turnInFlight: thinkingChatId !== null && thinkingChatId === active?.chatId,
|
||||
});
|
||||
// Held requests are re-asked by this same effect once the panel settles.
|
||||
if (target === "hold") return;
|
||||
handledRequestSeq.current = requestedMessage.seq;
|
||||
if (active) {
|
||||
setPrefill({ ...requestedMessage, chatId: active.chatId });
|
||||
} else {
|
||||
if (target === "new-chat") {
|
||||
void createChat(requestedMessage.text);
|
||||
return;
|
||||
}
|
||||
}, [requestedMessage, loading, active, createChat]);
|
||||
setSendRequest({ ...requestedMessage, chatId: active!.chatId });
|
||||
}, [requestedMessage, loading, active, thinkingChatId, createChat]);
|
||||
|
||||
// Carries its chat id so a later-mounted chat cannot adopt another chat's block.
|
||||
const [appendedMessages, setAppendedMessages] = useState<
|
||||
{ chatId: string; messages: UIMessage[]; seq: number } | undefined
|
||||
>(undefined);
|
||||
|
||||
const handledWatchSeq = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!watchRequest || handledWatchSeq.current === watchRequest.seq) return;
|
||||
handledWatchSeq.current = watchRequest.seq;
|
||||
dispatchWatchCard({
|
||||
type: "open",
|
||||
draft: watchDraftFor(watchRequest.spec),
|
||||
requestId: generateFriendlyId("wreq"),
|
||||
});
|
||||
}, [watchRequest]);
|
||||
|
||||
// Nothing is posted or persisted until the card is submitted.
|
||||
const openWatchCard = useCallback((spec: WatchSpec) => {
|
||||
dispatchWatchCard({
|
||||
type: "open",
|
||||
draft: watchDraftFor(spec),
|
||||
requestId: generateFriendlyId("wreq"),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dismissWatchCard = () => dispatchWatchCard({ type: "dismissed" });
|
||||
|
||||
const submitWatch = useCallback(async () => {
|
||||
const draft = watchCard.draft;
|
||||
if (!draft) return;
|
||||
// Held across retries, so a resubmit repairs the same pair of records.
|
||||
const clientRequestId = watchCard.requestId ?? generateFriendlyId("wreq");
|
||||
dispatchWatchCard({ type: "submitting", requestId: clientRequestId });
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.set("intent", "watch-create");
|
||||
body.set("draft", JSON.stringify(draft));
|
||||
body.set("clientRequestId", clientRequestId);
|
||||
// A watch is chat-bound: with no chat open the server creates one.
|
||||
if (active?.chatId) body.set("chatId", active.chatId);
|
||||
|
||||
const res = await fetch(actionPath, { method: "POST", body });
|
||||
const data = (await res.json()) as {
|
||||
chatId?: string;
|
||||
messages?: UIMessage[];
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok || !data.chatId || !data.messages) {
|
||||
dispatchWatchCard({
|
||||
type: "failed",
|
||||
error: data.error ?? "We couldn't start that watch. Try again in a moment.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = data.messages;
|
||||
if (active?.chatId === data.chatId) {
|
||||
setAppendedMessages((current) => ({
|
||||
chatId: data.chatId!,
|
||||
messages,
|
||||
seq: (current?.seq ?? 0) + 1,
|
||||
}));
|
||||
dispatchWatchCard({ type: "submitted" });
|
||||
} else {
|
||||
claimChatSlot();
|
||||
// No session: nothing is streaming and the records are the whole chat.
|
||||
setActive({
|
||||
chatId: data.chatId,
|
||||
messages,
|
||||
session: null,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
}
|
||||
void loadHistory();
|
||||
} catch (error) {
|
||||
console.error("Dashboard agent: failed to create watch", error);
|
||||
dispatchWatchCard({
|
||||
type: "failed",
|
||||
error: "We couldn't start that watch. Try again in a moment.",
|
||||
});
|
||||
}
|
||||
}, [
|
||||
watchCard.draft,
|
||||
watchCard.requestId,
|
||||
active?.chatId,
|
||||
actionPath,
|
||||
claimChatSlot,
|
||||
loadHistory,
|
||||
]);
|
||||
|
||||
const watchCardElement = watchCard.draft ? (
|
||||
<WatchCard
|
||||
draft={watchCard.draft}
|
||||
onChange={(draft) => dispatchWatchCard({ type: "edit", draft })}
|
||||
onSubmit={() => void submitWatch()}
|
||||
onCancel={dismissWatchCard}
|
||||
pending={watchCard.pending}
|
||||
error={watchCard.error}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const newChat = useCallback(() => {
|
||||
// Invalidate any in-flight open or create so its result can't replace the draft.
|
||||
openChatRequestSeq.current += 1;
|
||||
claimChatSlot();
|
||||
setLoading(false);
|
||||
setActive(null);
|
||||
}, []);
|
||||
}, [claimChatSlot]);
|
||||
|
||||
const switchChat = useCallback(
|
||||
(id: string) => {
|
||||
@@ -312,16 +512,64 @@ export function DashboardAgentPanel({
|
||||
[actionPath, active?.chatId, newChat, loadHistory, toast]
|
||||
);
|
||||
|
||||
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})`);
|
||||
// Empty when the watch had already resolved: then nothing was written.
|
||||
const data = (await res.json()) as { messages?: UIMessage[] };
|
||||
if (data.messages?.length) {
|
||||
const messages = data.messages;
|
||||
setAppendedMessages((current) => ({
|
||||
chatId,
|
||||
messages,
|
||||
seq: (current?.seq ?? 0) + 1,
|
||||
}));
|
||||
}
|
||||
} 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]
|
||||
);
|
||||
|
||||
// Titles are written when the first turn settles, so a new chat has none yet.
|
||||
const activeChat = active ? chats.find((chat) => chat.id === active.chatId) : undefined;
|
||||
const headerTitle = active ? (activeChat?.title ?? "Chat") : "New chat";
|
||||
|
||||
// Not filtered to active: the wake banner needs watches that already fired.
|
||||
const chatWatches = activeChat?.watches ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex h-full flex-col bg-background-bright animate-in slide-in-from-right-2 duration-150"
|
||||
// A React handler, not a global hotkey, so Esc stays scoped to the panel.
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
if (
|
||||
!escapeClosesPanel({
|
||||
key: event.key,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
targetInsidePanel: panelRef.current?.contains(event.target as Node) ?? false,
|
||||
})
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
@@ -355,7 +603,9 @@ export function DashboardAgentPanel({
|
||||
session={active.session}
|
||||
pendingFirstMessage={active.pendingFirstMessage}
|
||||
streaming={active.streaming}
|
||||
prefill={prefill && prefill.chatId === active.chatId ? prefill : undefined}
|
||||
sendRequest={
|
||||
sendRequest && sendRequest.chatId === active.chatId ? sendRequest : undefined
|
||||
}
|
||||
clientData={clientData}
|
||||
apiOrigin={apiOrigin}
|
||||
actionPath={actionPath}
|
||||
@@ -363,7 +613,14 @@ export function DashboardAgentPanel({
|
||||
environmentSlug={environment.slug}
|
||||
currentPage={currentPage}
|
||||
promotedPrompt={promotedPrompt}
|
||||
watches={chatWatches}
|
||||
pagePaths={pagePaths}
|
||||
watchCard={watchCardElement}
|
||||
appendedMessages={
|
||||
appendedMessages?.chatId === active.chatId ? appendedMessages : undefined
|
||||
}
|
||||
onWatchIntent={openWatchCard}
|
||||
onCancelWatch={cancelWatch}
|
||||
// The generated chat name is written before the turn-complete chunk lands.
|
||||
onTurnSettled={loadHistory}
|
||||
onActivityChange={handleActivityChange}
|
||||
@@ -376,6 +633,7 @@ export function DashboardAgentPanel({
|
||||
currentPage={currentPage}
|
||||
pageContext={pageContext}
|
||||
promotedPrompt={promotedPrompt}
|
||||
watchCard={watchCardElement}
|
||||
/>
|
||||
)}
|
||||
</AgentPanelColumn>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChartBarIcon,
|
||||
EyeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
SparklesIcon,
|
||||
@@ -22,6 +23,7 @@ export const PROMPT_SLOT_BUTTON: Record<
|
||||
> = {
|
||||
promoted: { variant: "primary/small", icon: SparklesIcon },
|
||||
investigate: { variant: "primary/small", icon: MagnifyingGlassIcon },
|
||||
watch: { variant: "secondary/small", icon: EyeIcon },
|
||||
status: { variant: "secondary/small", icon: ChartBarIcon },
|
||||
explain: { variant: "tertiary/small", icon: QuestionMarkCircleIcon },
|
||||
docs: { variant: "docs/small", icon: BookOpenIcon },
|
||||
|
||||
@@ -32,6 +32,7 @@ import { type ReportMessages } from "~/presenters/v3/reports/report-messages";
|
||||
import { AgentBadge } from "./agent-badges";
|
||||
import { seriesEndMs as toSeriesEndMs } from "./report-spark";
|
||||
import {
|
||||
FOOTER_WATCH_CODE,
|
||||
ReportBody,
|
||||
ReportCard,
|
||||
ReportFindingLine,
|
||||
@@ -50,9 +51,13 @@ import {
|
||||
ReportSeverityIcon,
|
||||
type ReportFooterItem,
|
||||
} from "./report-sparkline";
|
||||
import { reportOffersRecoveryWatch } from "./view-actions";
|
||||
|
||||
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 ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -313,6 +318,21 @@ export function ReportView({
|
||||
const linkByKey = (key: string | undefined) =>
|
||||
key === undefined ? undefined : vm.links.find((link) => link.key === key)?.url;
|
||||
|
||||
// Only offered when there is something to recover from, and only for the health
|
||||
// report, which is the one with a recovery watch kind.
|
||||
const recoveryWatch: AgentIntent | null = reportOffersRecoveryWatch(vm)
|
||||
? {
|
||||
kind: "watch",
|
||||
spec: {
|
||||
kind: "health_recovery",
|
||||
report: "health",
|
||||
fromSeverity: vm.summary.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(layout.footer.map((entry) => entry.link).filter(Boolean));
|
||||
|
||||
@@ -327,6 +347,22 @@ export function ReportView({
|
||||
}),
|
||||
}));
|
||||
|
||||
if (recoveryWatch && onIntent) {
|
||||
const watchItem: ReportFooterItem = {
|
||||
code: FOOTER_WATCH_CODE,
|
||||
// The label is deliberately the same everywhere; only the pre-filled spec is
|
||||
// contextual, so a per-object label would break the pattern.
|
||||
node: <ReportFooterAction onClick={() => onIntent(recoveryWatch)}>Watch…</ReportFooterAction>,
|
||||
};
|
||||
// The watch joins the other buttons, before the trailing prose entry.
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The banner above a wake narration: the label, the icon and the tone frame, and
|
||||
* nothing else. The narration under it states the headline, the user's note and the
|
||||
* next step, and each of those is said once per wake — so the banner marks the
|
||||
* message as a wake rather than restating it.
|
||||
*
|
||||
* This component holds no kind-specific wording: tone and semantic icon come from
|
||||
* contracts and `app/presenters/v3/dashboardAgent`. All it 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 suffix is the transport encoding, not the outcome; 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 "~/presenters/v3/dashboardAgent";
|
||||
|
||||
const WAKE_ID_PREFIX = "wake:watch:";
|
||||
|
||||
/**
|
||||
* The wire encoding in a wake's message id, not the resolution: `window_completed`
|
||||
* and `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. 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.
|
||||
* 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.
|
||||
*/
|
||||
export function wakeResolution(
|
||||
outcome: WakeOutcome,
|
||||
watch: Pick<WakeWatch, "resolution" | "endedReason"> | 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, 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 to glyph. Which icon a resolved result deserves is decided in
|
||||
* contracts, and the rule there is that the icon follows the observed outcome, not
|
||||
* the resolution: a failed run gets `error`, not the check its `condition_met`
|
||||
* would suggest.
|
||||
*/
|
||||
const SEMANTIC_ICON: Record<WatchSemanticIcon, (props: { className?: string }) => JSX.Element> = {
|
||||
success: CheckCircleIcon,
|
||||
attention: ExclamationTriangleIcon,
|
||||
error: ExclamationCircleIcon,
|
||||
waiting: ClockIcon,
|
||||
info: InformationCircleIcon,
|
||||
};
|
||||
|
||||
const TONE_FRAME: Record<AgentTone, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
export function WakeBanner({
|
||||
outcome,
|
||||
watch,
|
||||
}: {
|
||||
/** The wire encoding from the wake's message id. */
|
||||
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];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center gap-2 rounded-r-md border-l-2 px-3 py-2", TONE_FRAME[tone])}
|
||||
>
|
||||
<Icon className={cn("size-4 shrink-0", TONE_ICON_COLOR[tone])} />
|
||||
<p className="text-xxs font-medium uppercase tracking-wider text-text-dimmed">
|
||||
{presentation.label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { EyeIcon } from "@heroicons/react/20/solid";
|
||||
import type { WatchSpec } from "@internal/dashboard-agent-contracts";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { useDashboardAgent } from "./dashboardAgentLauncher";
|
||||
import { watchTooltipLabel } from "~/presenters/v3/dashboardAgent";
|
||||
|
||||
/** Posts nothing: opens the panel with the card pre-filled. Renders nothing without a provider. */
|
||||
export function WatchButton({
|
||||
spec,
|
||||
label = "Watch…",
|
||||
size = "small",
|
||||
variant = "secondary",
|
||||
fullWidth,
|
||||
className,
|
||||
tooltip,
|
||||
}: {
|
||||
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 (
|
||||
<Button
|
||||
type="button"
|
||||
variant={`${variant}/${size}`}
|
||||
LeadingIcon={EyeIcon}
|
||||
leadingIconClassName={variant === "primary" ? undefined : "text-text-dimmed"}
|
||||
fullWidth={fullWidth}
|
||||
textAlignLeft={fullWidth}
|
||||
className={className}
|
||||
tooltip={tooltip ?? watchTooltipLabel(spec)}
|
||||
onClick={() => agent.openWithWatch(spec)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* The watch configuration card, opened by the Watch action.
|
||||
*
|
||||
* Rules it keeps: the card is ephemeral until submitted (it lives in the panel,
|
||||
* not the transcript, and only a submitted outcome is persisted as a
|
||||
* `watch_result` block); Customize expands in place, never a modal; in-chat
|
||||
* delivery is stated as a line, so the two opt-ins stay independent checkboxes
|
||||
* and never become a radio group.
|
||||
*
|
||||
* Pure component: draft in, markup and callbacks out. Draft rules live in
|
||||
* `watch-card.ts` and wording in `app/presenters/v3/dashboardAgent`.
|
||||
*/
|
||||
import { EyeIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
WATCH_WINDOW_HOURS_OPTIONS,
|
||||
watchCadenceOptions,
|
||||
type WatchDraft,
|
||||
type WatchKind,
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } 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 {
|
||||
variantsOf,
|
||||
watchDraftError,
|
||||
withAgeMinutes,
|
||||
withCadence,
|
||||
withFollowUp,
|
||||
withThreshold,
|
||||
withVariant,
|
||||
withWindow,
|
||||
} from "./watch-card";
|
||||
import {
|
||||
formatWatchCadence,
|
||||
formatWatchWindow,
|
||||
WATCH_IN_CHAT_DELIVERY_LINE,
|
||||
watchConditionLabel,
|
||||
watchDurationLabel,
|
||||
watchSubjectLabel,
|
||||
} from "~/presenters/v3/dashboardAgent";
|
||||
|
||||
/** How the condition variants are named in the picker. Short, not sentences. */
|
||||
const VARIANT_LABEL: Record<WatchKind, string> = {
|
||||
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",
|
||||
queue_depth_below: "when it's back below",
|
||||
queue_stalled: "if it stops moving",
|
||||
queue_oldest_age: "if runs wait too long",
|
||||
error_recurrence: "if it recurs",
|
||||
health_recovery: "when it recovers",
|
||||
};
|
||||
|
||||
/** Hoisted so the submit button's icon component keeps a stable identity. */
|
||||
function ButtonSpinner() {
|
||||
return <AgentSpinner size={14} />;
|
||||
}
|
||||
|
||||
/** Controlled, unlike `CheckboxWithLabel`: the draft is the only thing that says what's on. */
|
||||
function Toggle({
|
||||
label,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className={cn("group flex w-fit items-start gap-x-2", disabled && "opacity-70")}>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"mt-0.5 select-none text-sm text-text-bright",
|
||||
disabled ? "cursor-default" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One choice in an inline picker. */
|
||||
function Choice({
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onSelect: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-xs transition focus-custom",
|
||||
selected
|
||||
? "border-border-brightest bg-background-bright text-text-bright"
|
||||
: "border-border-bright text-text-dimmed hover:text-text-bright",
|
||||
disabled && "cursor-default opacity-70 hover:text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xxs uppercase tracking-wide text-text-faint">{label}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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). */
|
||||
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 variants = variantsOf(draft);
|
||||
// Local validation first: a draft the schema would refuse never reaches the server.
|
||||
const localError = watchDraftError(draft);
|
||||
const blocked = localError !== null || pending;
|
||||
|
||||
return (
|
||||
<ChatSystemBlock
|
||||
label="Watch"
|
||||
icon={<EyeIcon className="size-3.5 shrink-0 text-text-dimmed" />}
|
||||
actions={
|
||||
<>
|
||||
{/* One confirm, expanded or not: an expanded card is submitted as shown. */}
|
||||
<Button
|
||||
variant="primary/small"
|
||||
disabled={blocked}
|
||||
LeadingIcon={pending ? ButtonSpinner : undefined}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{pending ? "Starting…" : "Watch"}
|
||||
</Button>
|
||||
{!expanded ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
disabled={pending}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Customize
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancel ? (
|
||||
<Button variant="minimal/small" disabled={pending} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="truncate text-sm font-medium text-text-bright">
|
||||
Watch {watchSubjectLabel(spec)}
|
||||
</p>
|
||||
{!expanded ? (
|
||||
<>
|
||||
<p className="text-xs text-text-dimmed">{watchConditionLabel(spec)}</p>
|
||||
<p className="text-xs text-text-dimmed">{watchDurationLabel(spec)}</p>
|
||||
</>
|
||||
) : null}
|
||||
<p className="text-xs text-text-dimmed">{WATCH_IN_CHAT_DELIVERY_LINE}</p>
|
||||
|
||||
{expanded ? (
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
{/* Kinds with no second condition variant must not show an empty picker. */}
|
||||
{variants.length > 1 ? (
|
||||
<Field label="Tell me">
|
||||
{variants.map((kind) => (
|
||||
<Choice
|
||||
key={kind}
|
||||
selected={kind === spec.kind}
|
||||
disabled={pending}
|
||||
onSelect={() => {
|
||||
if (kind !== spec.kind) onChange(withVariant(draft, kind));
|
||||
}}
|
||||
>
|
||||
{VARIANT_LABEL[kind]}
|
||||
</Choice>
|
||||
))}
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Tell me">
|
||||
<span className="text-xs text-text-dimmed">{watchConditionLabel(spec)}</span>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* One contextual parameter per condition, only where one exists. */}
|
||||
{spec.kind === "queue_depth_above" || spec.kind === "queue_depth_below" ? (
|
||||
<Field label={spec.kind === "queue_depth_above" ? "Above" : "Below"}>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
variant="small"
|
||||
className="w-28"
|
||||
disabled={pending}
|
||||
// A half-typed field must show empty, not "NaN"; `watchDraftError`
|
||||
// is what refuses to submit it.
|
||||
value={Number.isFinite(spec.threshold) ? String(spec.threshold) : ""}
|
||||
onChange={(event) =>
|
||||
onChange(withThreshold(draft, Number.parseInt(event.target.value, 10)))
|
||||
}
|
||||
aria-label="Queue depth threshold"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
{spec.kind === "queue_oldest_age" ? (
|
||||
<Field label="Waiting longer than">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
variant="small"
|
||||
className="w-28"
|
||||
disabled={pending}
|
||||
value={Number.isFinite(spec.thresholdMinutes) ? String(spec.thresholdMinutes) : ""}
|
||||
onChange={(event) =>
|
||||
onChange(withAgeMinutes(draft, Number.parseInt(event.target.value, 10)))
|
||||
}
|
||||
aria-label="Wait limit in minutes"
|
||||
/>
|
||||
<span className="text-xs text-text-dimmed">minutes</span>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label="For">
|
||||
{WATCH_WINDOW_HOURS_OPTIONS.map((hours) => (
|
||||
<Choice
|
||||
key={hours}
|
||||
selected={spec.maxHours === hours}
|
||||
disabled={pending}
|
||||
onSelect={() => onChange(withWindow(draft, hours))}
|
||||
>
|
||||
{formatWatchWindow(hours)}
|
||||
</Choice>
|
||||
))}
|
||||
</Field>
|
||||
|
||||
{/* Cadence options come from the kind's schema limits, so an aggregate
|
||||
watch can never be offered a 1-minute hot loop. */}
|
||||
<Field label="Checking">
|
||||
{watchCadenceOptions(spec.kind).map((minutes) => (
|
||||
<Choice
|
||||
key={minutes}
|
||||
selected={spec.checkEveryMinutes === minutes}
|
||||
disabled={pending}
|
||||
onSelect={() => onChange(withCadence(draft, minutes))}
|
||||
>
|
||||
{formatWatchCadence(minutes)}
|
||||
</Choice>
|
||||
))}
|
||||
</Field>
|
||||
|
||||
{/* Two independent opt-ins under a fixed delivery line, never a radio
|
||||
group, so "email instead of chat" is not expressible. */}
|
||||
<Field label="When there's an answer">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Toggle
|
||||
label="Investigate attention outcomes"
|
||||
checked={draft.followUp.investigateOnAttention}
|
||||
disabled={pending}
|
||||
onChange={(checked) =>
|
||||
onChange(withFollowUp(draft, { investigateOnAttention: checked }))
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Also notify me externally"
|
||||
checked={draft.followUp.notifyExternally}
|
||||
disabled={pending}
|
||||
onChange={(checked) => onChange(withFollowUp(draft, { notifyExternally: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Errors live and die with the card: nothing is persisted. */}
|
||||
{localError || error ? (
|
||||
<p className="pt-1 text-xs text-error">{localError ?? error}</p>
|
||||
) : null}
|
||||
</ChatSystemBlock>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* There is no rendering harness here, so this pins the prop that decides the tab order
|
||||
* rather than the tab order itself: `SimpleTooltip` sets `tabIndex={-1}` unless `tabbable`
|
||||
* is passed. What it does not prove is that focus actually opens the tooltip.
|
||||
*/
|
||||
describe("the watch chip's tooltips are reachable by keyboard", () => {
|
||||
const source = readFileSync(new URL("./WatchChips.tsx", import.meta.url), "utf8");
|
||||
|
||||
it("marks both tabbable — the label one carries status, cadence and expiry", () => {
|
||||
expect(source.match(/<SimpleTooltip/g) ?? []).toHaveLength(2);
|
||||
expect(source.match(/\btabbable\b/g) ?? []).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
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";
|
||||
|
||||
/** As the panel's loader hands it over: dates are already JSON strings. */
|
||||
export type WatchChip = {
|
||||
id: string;
|
||||
identity: string;
|
||||
status: WatchStatus;
|
||||
kind: string;
|
||||
note: string;
|
||||
checkEveryMinutes: number;
|
||||
expiresAt: string;
|
||||
endedReason?: string | null;
|
||||
/** Null while active; absent on rows written before the resolution column. */
|
||||
resolution?: WatchResolution | null;
|
||||
observedOutcome?: WatchObservedOutcome | null;
|
||||
};
|
||||
|
||||
const SEMANTIC_ICON: Record<WatchSemanticIcon, (props: { className?: string }) => 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: a
|
||||
* `run_finished` watch on a failed run resolves `condition_met`. Cancellation has none.
|
||||
*/
|
||||
function StatusIcon({ watch }: { watch: WatchChip }) {
|
||||
if (watch.status === "active") return <AgentSpinner size={14} />;
|
||||
|
||||
if (watch.status === "cancelled") {
|
||||
return <NoSymbolIcon className={cn("size-3.5 shrink-0", TONE_ICON_COLOR.neutral as string)} />;
|
||||
}
|
||||
|
||||
const presentation = wakePresentation(watch.status === "fired" ? "fired" : "expired", watch);
|
||||
const Icon = SEMANTIC_ICON[presentation.semanticIcon];
|
||||
return (
|
||||
<Icon className={cn("size-3.5 shrink-0", TONE_ICON_COLOR[presentation.tone as AgentTone])} />
|
||||
);
|
||||
}
|
||||
|
||||
export function WatchChips({
|
||||
watches,
|
||||
onCancel,
|
||||
}: {
|
||||
watches: WatchChip[];
|
||||
onCancel?: (watchId: string) => void;
|
||||
}) {
|
||||
if (watches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-text-faint">watches</span>
|
||||
{watches.map((watch) => {
|
||||
const label = watchChipLabel(watch);
|
||||
return (
|
||||
<span
|
||||
key={watch.id}
|
||||
// No native `title`: it would stack with the custom tooltip.
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border-bright bg-background-bright py-0.5 pl-2 pr-1.5 text-xs text-text-bright"
|
||||
>
|
||||
<StatusIcon watch={watch} />
|
||||
<SimpleTooltip
|
||||
// Status, cadence and expiry live only here, so it needs a tab stop.
|
||||
tabbable
|
||||
side="bottom"
|
||||
content={watchChipTooltip(watch)}
|
||||
button={<span className="max-w-[12rem] truncate">{label}</span>}
|
||||
/>
|
||||
{watch.status === "active" && onCancel ? (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
tabbable
|
||||
side="bottom"
|
||||
content={`Cancel the ${label} watch`}
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Cancel the ${label} watch`}
|
||||
onClick={() => onCancel(watch.id)}
|
||||
className="text-text-faint transition-colors hover:text-error focus-visible:text-error focus-custom"
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* What a submitted watch card leaves in the transcript, in two flavours.
|
||||
*
|
||||
* A confirmation states the watch's lifetime facts and is the only transcript record
|
||||
* of the request. A one-shot result means the immediate check answered outright and
|
||||
* no watch was created, so no chip appears, no wake arrives and there is nothing to
|
||||
* cancel.
|
||||
*
|
||||
* Pure component: the wording is not computed here, it was frozen into the block at
|
||||
* append time by `app/presenters/v3/dashboardAgent`, 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 a success (nothing has happened
|
||||
* yet) so it wears the neutral eye; the check belongs to the one-shot that 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 (
|
||||
<ChatSystemBlock
|
||||
label={label}
|
||||
icon={<Icon className={cn("size-3.5 shrink-0", TONE_ICON_COLOR[tone])} />}
|
||||
>
|
||||
<p className="text-sm text-text-bright">{block.headline}</p>
|
||||
{block.lifetime ? <p className="text-xs text-text-dimmed">{block.lifetime}</p> : null}
|
||||
{block.detail ? <p className="text-xs text-text-dimmed">{block.detail}</p> : null}
|
||||
{(block.followUp ?? []).map((line) => (
|
||||
<p key={line} className="text-xs text-text-dimmed">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</ChatSystemBlock>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The dashboard-wide signal that a watch woke a chat while the panel was closed.
|
||||
*
|
||||
* Persistent by design: a wake answers a question asked minutes or hours ago, so it
|
||||
* waits until dismissed rather than expiring on a timer. Dismissing does not mark
|
||||
* the chat read (reading happens in the panel), so the launcher's dot survives a
|
||||
* swatted toast.
|
||||
*/
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { ToastUI } from "~/components/primitives/Toast";
|
||||
import type { WatchObservedOutcome, WatchResolution } from "@internal/dashboard-agent-contracts";
|
||||
import { wakeResolution } from "./WakeBanner";
|
||||
import { presentResolvedWatch, WATCH_PRESENTATION_FALLBACK } from "~/presenters/v3/dashboardAgent";
|
||||
|
||||
/** 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. Not the outcome; see `resolution`. */
|
||||
outcome: "fired" | "expired";
|
||||
note: string;
|
||||
/**
|
||||
* What actually happened, frozen on the row by the resolving check. The toast,
|
||||
* the banner and the email take their headline from the same presenter so they
|
||||
* cannot disagree. Absent on a row written before the resolution model, where the
|
||||
* presenter falls back rather than guessing.
|
||||
*/
|
||||
kind?: string;
|
||||
identity?: string;
|
||||
resolution?: WatchResolution | null;
|
||||
observedOutcome?: WatchObservedOutcome | null;
|
||||
/** Landed after the chat's read marker. The dot counts these; the toast fires either way. */
|
||||
unread?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The toast's title: the fact, or the neutral fallback when this wake predates the
|
||||
* resolution model. The wording is `app/presenters/v3/dashboardAgent`'s; 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 (
|
||||
<ToastUI
|
||||
variant="agent"
|
||||
t={t}
|
||||
title={title}
|
||||
message={message}
|
||||
toastWidth={TOAST_WIDTH}
|
||||
actionNode={
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
className="my-2 self-start"
|
||||
onClick={() => {
|
||||
onOpenChat();
|
||||
toast.dismiss(t);
|
||||
}}
|
||||
>
|
||||
Open chat
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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, not whichever chat the panel had open last.
|
||||
*/
|
||||
export function showWatchWakeToast(wake: WatchWake, onOpenChat: (chatId: string) => void) {
|
||||
show(
|
||||
(t) => (
|
||||
<WakeToastUI
|
||||
t={t}
|
||||
title={watchWakeToastTitle(wake)}
|
||||
message={wake.note}
|
||||
onOpenChat={() => onOpenChat(wake.chatId)}
|
||||
/>
|
||||
),
|
||||
`watch-wake-${wake.watchId}`
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
const WAKES_SUMMARY_TOAST_ID = "watch-wakes-summary";
|
||||
|
||||
/** One persistent toast standing in for a batch too large to narrate one by one. */
|
||||
export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void) {
|
||||
show(
|
||||
(t) => (
|
||||
<WakeToastUI
|
||||
t={t}
|
||||
title="Watch updates"
|
||||
message={`${count} watch update${count === 1 ? "" : "s"} — open the chat panel.`}
|
||||
onOpenChat={onOpenChat}
|
||||
/>
|
||||
),
|
||||
WAKES_SUMMARY_TOAST_ID
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the summary off screen. Its count only means anything until the user opens the
|
||||
* panel; left up, a later poll would rewrite it to a smaller number.
|
||||
*/
|
||||
export function dismissWatchWakesSummaryToast() {
|
||||
toast.dismiss(WAKES_SUMMARY_TOAST_ID);
|
||||
}
|
||||
@@ -80,6 +80,7 @@ describe("chat-layout enforcement", () => {
|
||||
"ChatToolRow",
|
||||
"ChatNote",
|
||||
"ChatStatusLine",
|
||||
"ChatWakeSlot",
|
||||
"ChatActionsRow",
|
||||
]) {
|
||||
expect(source, name).toContain(`export function ${name}(`);
|
||||
|
||||
@@ -12,6 +12,7 @@ const TURN_GAP = "space-y-4";
|
||||
const TURN_BODY_GAP = "space-y-2";
|
||||
const ROW_GAP = "gap-2";
|
||||
const CHIP_GAP = "gap-1.5";
|
||||
const UNIT_GAP = "space-y-1.5";
|
||||
|
||||
const SCROLLER =
|
||||
"flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control";
|
||||
@@ -148,6 +149,21 @@ export function ChatStatusLine({
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatWakeSlot({
|
||||
banner,
|
||||
children,
|
||||
}: {
|
||||
banner: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("min-w-0", UNIT_GAP)}>
|
||||
{banner}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BLOCK_LINE_GAP = "space-y-1";
|
||||
const BLOCK_INSET = "px-3 py-2.5";
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -21,6 +22,12 @@ type DashboardAgentContextValue = {
|
||||
setOpen: (open: boolean) => void;
|
||||
/** Sent as the first message of a new chat; with a chat open it only fills the composer. */
|
||||
openWith: (text: string) => void;
|
||||
/** Nothing is posted or persisted until the card is submitted. */
|
||||
openWithWatch: (spec: WatchSpec) => void;
|
||||
/** Polled only while the panel is closed; 0 while it is open. */
|
||||
unreadWakes: number;
|
||||
/** Chats that answered, settled or woke while the panel was closed. */
|
||||
unreadWork: number;
|
||||
};
|
||||
|
||||
const DashboardAgentContext = createContext<DashboardAgentContextValue | null>(null);
|
||||
@@ -38,11 +45,13 @@ export function DashboardAgentLauncher() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { open, setOpen } = agent;
|
||||
const { open, setOpen, unreadWakes, unreadWork } = agent;
|
||||
if (open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasUnread = unreadWakes > 0 || unreadWork > 0;
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
@@ -58,11 +67,15 @@ export function DashboardAgentLauncher() {
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<Button
|
||||
variant="ask-trigger/small"
|
||||
aria-label={ASK_AGENT_LABEL}
|
||||
aria-label={hasUnread ? `${ASK_AGENT_LABEL}, unread updates` : ASK_AGENT_LABEL}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{ASK_AGENT_LABEL}
|
||||
</Button>
|
||||
{hasUnread && (
|
||||
// The ring matches the `NavBar` surface the launcher sits on.
|
||||
<span className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-indigo-500 ring-2 ring-background-bright" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const demoConcurrencySaturationSignal: AgentPageSignal = {
|
||||
severity: "crit",
|
||||
};
|
||||
|
||||
// Priority order.
|
||||
// Priority order. `SIGNAL_PRIORITY` in the registry mirrors this.
|
||||
export const demoSignalsByPriority: AgentPageSignal[] = [
|
||||
demoFreshFailureSignal,
|
||||
demoWaitingRunSignal,
|
||||
@@ -141,6 +141,12 @@ export const demoPromptSets: Record<DemoPageContextKey, SuggestedPrompt[]> = {
|
||||
"How many other runs failed with this error in the last hour?",
|
||||
"contextual"
|
||||
),
|
||||
prompt(
|
||||
"watch-retry",
|
||||
"Tell me when it retries",
|
||||
`Watch ${DEMO_WORLD.failedRunId} and tell me when it finishes.`,
|
||||
"contextual"
|
||||
),
|
||||
DEFAULT_PROMPTS[1]!,
|
||||
],
|
||||
waitingRun: [
|
||||
@@ -195,6 +201,12 @@ export const demoPromptSets: Record<DemoPageContextKey, SuggestedPrompt[]> = {
|
||||
"Explain this error and what usually causes it.",
|
||||
"promoted"
|
||||
),
|
||||
prompt(
|
||||
"watch-recurrence",
|
||||
"Tell me if it comes back",
|
||||
"Watch this error and tell me if it happens again.",
|
||||
"contextual"
|
||||
),
|
||||
DEFAULT_PROMPTS[1]!,
|
||||
],
|
||||
queue: [
|
||||
@@ -224,7 +236,7 @@ export const demoPromptSets: Record<DemoPageContextKey, SuggestedPrompt[]> = {
|
||||
other: DEFAULT_PROMPTS,
|
||||
};
|
||||
|
||||
export const demoDismissedPromptIds: string[] = [];
|
||||
export const demoDismissedPromptIds: string[] = [demoId("prompt-watch-retry")];
|
||||
|
||||
export const demoPromptsAfterDismissal: SuggestedPrompt[] = demoPromptSets.failedRun
|
||||
.filter((p) => !demoDismissedPromptIds.includes(p.id))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { explicitPromptTarget } from "./explicit-prompt";
|
||||
|
||||
describe("explicitPromptTarget", () => {
|
||||
it("starts a chat when the panel has none", () => {
|
||||
expect(explicitPromptTarget({ chat: "none", turnInFlight: false })).toBe("new-chat");
|
||||
});
|
||||
|
||||
it("sends into the chat the user is already in", () => {
|
||||
expect(explicitPromptTarget({ chat: "open", turnInFlight: false })).toBe("send-to-open-chat");
|
||||
});
|
||||
|
||||
it("holds while a chat is still opening, rather than racing it into a new one", () => {
|
||||
expect(explicitPromptTarget({ chat: "opening", turnInFlight: false })).toBe("hold");
|
||||
});
|
||||
|
||||
it("holds while the open chat is mid-turn instead of barging in", () => {
|
||||
expect(explicitPromptTarget({ chat: "open", turnInFlight: true })).toBe("hold");
|
||||
});
|
||||
|
||||
it("never fills the composer and leaves the sending to the user", () => {
|
||||
const targets = (["none", "opening", "open"] as const).flatMap((chat) =>
|
||||
[true, false].map((turnInFlight) => explicitPromptTarget({ chat, turnInFlight }))
|
||||
);
|
||||
expect(targets).not.toContain("prefill");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Structural guards, not behavioural proof: whether a held request is asked again, and whether
|
||||
* the old prefill path is really gone, live in the wiring rather than in the rule.
|
||||
*/
|
||||
describe("the panel sends every explicit prompt", () => {
|
||||
const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8");
|
||||
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
|
||||
|
||||
it("routes through the shared rule", () => {
|
||||
expect(panel).toContain("explicitPromptTarget({");
|
||||
});
|
||||
|
||||
it("keeps a held request pending instead of marking it handled", () => {
|
||||
const effect = panel.slice(panel.indexOf("const target = explicitPromptTarget({"));
|
||||
expect(effect.indexOf('if (target === "hold") return;')).toBeLessThan(
|
||||
effect.indexOf("handledRequestSeq.current = requestedMessage.seq;")
|
||||
);
|
||||
});
|
||||
|
||||
it("re-asks once the panel settles, so a hold cannot strand the prompt", () => {
|
||||
expect(panel).toContain("}, [requestedMessage, loading, active, thinkingChatId, createChat]);");
|
||||
});
|
||||
|
||||
it("leaves no prefill path behind", () => {
|
||||
expect(panel).not.toMatch(/prefill/i);
|
||||
expect(chat).not.toMatch(/prefill/i);
|
||||
});
|
||||
|
||||
it("carries a first prompt on the new chat itself, not on a request that a switch clears", () => {
|
||||
const effect = panel.slice(panel.indexOf("const target = explicitPromptTarget({"));
|
||||
expect(effect.indexOf("void createChat(requestedMessage.text);")).toBeLessThan(
|
||||
effect.indexOf("setSendRequest({ ...requestedMessage")
|
||||
);
|
||||
expect(panel).toContain("pendingFirstMessage: data.headStarted ? undefined : text,");
|
||||
});
|
||||
|
||||
it("drops the request when the chat slot changes, so switching back cannot re-send it", () => {
|
||||
const claim = panel.slice(
|
||||
panel.indexOf("const claimChatSlot = useCallback(() => {"),
|
||||
panel.indexOf("const openChat = useCallback(")
|
||||
);
|
||||
expect(claim).toContain("setSendRequest(undefined);");
|
||||
});
|
||||
|
||||
it("submits the request in the chat rather than typing it into the composer", () => {
|
||||
expect(chat).toContain("submit(sendRequest.text);");
|
||||
expect(chat).not.toContain("setInput(sendRequest.text)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** What to do with a prompt the user asked for by clicking, rather than by typing. */
|
||||
export type ExplicitPromptTarget = "new-chat" | "send-to-open-chat" | "hold";
|
||||
|
||||
/**
|
||||
* A click on Investigate or a prompt chip always ends in a sent message; only where it lands
|
||||
* depends on the panel. `hold` is not a refusal — the request stays pending and is asked again
|
||||
* once the chat has opened or its turn has finished.
|
||||
*/
|
||||
export function explicitPromptTarget(panel: {
|
||||
chat: "none" | "opening" | "open";
|
||||
turnInFlight: boolean;
|
||||
}): ExplicitPromptTarget {
|
||||
if (panel.chat === "opening") return "hold";
|
||||
if (panel.chat === "none") return "new-chat";
|
||||
return panel.turnInFlight ? "hold" : "send-to-open-chat";
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function AgentListRow({
|
||||
meta,
|
||||
status,
|
||||
variant = "default",
|
||||
unread = false,
|
||||
onSelect,
|
||||
action,
|
||||
}: {
|
||||
@@ -27,6 +28,7 @@ export function AgentListRow({
|
||||
meta?: ReactNode;
|
||||
status?: ReactNode;
|
||||
variant?: AgentListRowVariant;
|
||||
unread?: boolean;
|
||||
onSelect: () => void;
|
||||
/** Use {@link AgentListRowAction}. */
|
||||
action?: ReactNode;
|
||||
@@ -38,12 +40,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 ? (
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">{status}</span>
|
||||
) : null}
|
||||
{unread ? (
|
||||
<>
|
||||
<span aria-hidden className="size-2 shrink-0 rounded-full bg-indigo-500" />
|
||||
<span className="sr-only">Unread.</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="line-clamp-1 min-w-0 flex-1">{label}</span>
|
||||
{meta ? <span className="shrink-0 text-xs text-text-faint">{meta}</span> : null}
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts";
|
||||
|
||||
// Counted per user across their chats in the org, not per chat, which "New chat"
|
||||
// would reset.
|
||||
export const FREE_PLAN_MESSAGE_LIMIT = 20;
|
||||
@@ -25,6 +27,12 @@ export function resolveMessageQuota({
|
||||
: { kind: "within", used, limit, remaining };
|
||||
}
|
||||
|
||||
export function countUserMessages(messages: { role: string }[]): number {
|
||||
return messages.reduce((total, message) => (message.role === "user" ? total + 1 : total), 0);
|
||||
// A watch's consent record is a user message the person never typed, so it is
|
||||
// excluded here exactly as the stored count excludes it.
|
||||
export function countUserMessages(messages: { role: string; id?: string }[]): number {
|
||||
return messages.reduce(
|
||||
(total, message) =>
|
||||
message.role === "user" && !isWatchRequestMessageId(message.id) ? total + 1 : total,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeClosesPanel } from "./panel-escape";
|
||||
|
||||
/**
|
||||
* Escape has to reach the thing the user meant. Radix dismisses a popover or a dialog from a
|
||||
* document listener that runs after the panel's own handler and never marks the event handled,
|
||||
* so the panel has to decide for itself whether the keystroke came from inside it.
|
||||
*/
|
||||
describe("escapeClosesPanel", () => {
|
||||
it("closes the panel when Escape comes from the panel itself", () => {
|
||||
expect(
|
||||
escapeClosesPanel({ key: "Escape", defaultPrevented: false, targetInsidePanel: true })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the panel open when Escape comes from a portalled layer", () => {
|
||||
// The history popover and the delete dialog both render outside the panel's DOM subtree.
|
||||
expect(
|
||||
escapeClosesPanel({ key: "Escape", defaultPrevented: false, targetInsidePanel: false })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stays out of the way once something else has handled the key", () => {
|
||||
expect(
|
||||
escapeClosesPanel({ key: "Escape", defaultPrevented: true, targetInsidePanel: true })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores every other key", () => {
|
||||
expect(
|
||||
escapeClosesPanel({ key: "Enter", defaultPrevented: false, targetInsidePanel: true })
|
||||
).toBe(false);
|
||||
expect(escapeClosesPanel({ key: "j", defaultPrevented: false, targetInsidePanel: true })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Structural guards, not behavioural proof: the delete confirmation's survival depends on where
|
||||
* it is mounted in the tree, which these assertions pin down without rendering anything.
|
||||
*/
|
||||
describe("the delete confirmation lives outside the history popover", () => {
|
||||
const header = readFileSync(new URL("./DashboardAgentHeader.tsx", import.meta.url), "utf8");
|
||||
const history = readFileSync(new URL("./DashboardAgentHistory.tsx", import.meta.url), "utf8");
|
||||
const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
const menuBody = history.slice(
|
||||
history.indexOf("export function DashboardAgentHistoryMenu"),
|
||||
history.indexOf("export function DashboardAgentDeleteChatDialog")
|
||||
);
|
||||
|
||||
it("keeps no dialog and no pending state inside the popover's menu", () => {
|
||||
expect(menuBody).not.toContain("<Dialog");
|
||||
expect(menuBody).not.toContain("useState");
|
||||
});
|
||||
|
||||
it("mounts the dialog in the header as a sibling of the popover, not within it", () => {
|
||||
const popoverEnd = header.indexOf("</Popover>");
|
||||
const dialog = header.indexOf("<DashboardAgentDeleteChatDialog");
|
||||
expect(popoverEnd).toBeGreaterThan(-1);
|
||||
expect(dialog).toBeGreaterThan(popoverEnd);
|
||||
});
|
||||
|
||||
it("owns the pending chat in the header, so dismissing the popover cannot unmount it", () => {
|
||||
expect(header).toContain("const [pendingDelete, setPendingDelete] = useState");
|
||||
});
|
||||
|
||||
it("gates the panel's Escape on the shared rule rather than defaultPrevented alone", () => {
|
||||
expect(panel).toContain("escapeClosesPanel({");
|
||||
expect(panel).toContain("panelRef.current?.contains(event.target as Node)");
|
||||
expect(panel).not.toContain('if (event.key !== "Escape" || event.defaultPrevented) return;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Escape inside the panel closes the panel — but a popover or a dialog is portalled out of
|
||||
* the panel's DOM subtree while still bubbling through the React tree, and Radix dismisses
|
||||
* those from a document listener that runs after this handler, so the event arrives here
|
||||
* undefaulted. Deciding on the DOM target is what tells the two apart.
|
||||
*/
|
||||
export function escapeClosesPanel(event: {
|
||||
key: string;
|
||||
defaultPrevented: boolean;
|
||||
/** Whether the event's target is a DOM descendant of the panel. */
|
||||
targetInsidePanel: boolean;
|
||||
}): boolean {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return false;
|
||||
return event.targetInsidePanel;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pendingNavigateIntents } from "./pending-intents";
|
||||
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
|
||||
|
||||
describe("pendingNavigateIntents", () => {
|
||||
const uri = "trigger://proj_abc/env_123/run/run_abc";
|
||||
@@ -46,3 +46,75 @@ describe("pendingNavigateIntents", () => {
|
||||
).toEqual([{ kind: "navigate", target: uri }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingWatchIntents", () => {
|
||||
const spec = {
|
||||
kind: "run_finished",
|
||||
runId: "run_abc",
|
||||
checkEveryMinutes: 1,
|
||||
maxHours: 2,
|
||||
note: "tell me when the receipt run finishes",
|
||||
};
|
||||
const toolPart = (toolCallId: string, state = "output-available") => ({
|
||||
type: "tool-schedule_watch",
|
||||
state,
|
||||
toolCallId,
|
||||
output: { intent: { kind: "watch", spec } },
|
||||
});
|
||||
|
||||
it("returns the proposed spec from a completed schedule_watch call, once", () => {
|
||||
const seen = new Set<string>();
|
||||
const messages = [{ id: "m1", parts: [toolPart("call-1")] }];
|
||||
|
||||
expect(pendingWatchIntents(messages, seen)).toEqual([{ kind: "watch", spec }]);
|
||||
expect(pendingWatchIntents(messages, seen)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a call still running, and a spec the contract rejects", () => {
|
||||
expect(
|
||||
pendingWatchIntents([{ id: "m1", parts: [toolPart("call-1", "input-available")] }], new Set())
|
||||
).toEqual([]);
|
||||
|
||||
const invalid = [
|
||||
{
|
||||
id: "m1",
|
||||
parts: [
|
||||
{
|
||||
...toolPart("call-2"),
|
||||
output: { intent: { kind: "watch", spec: { kind: "run_finished" } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(pendingWatchIntents(invalid, new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it("never reopens a proposal seeded from loaded history", () => {
|
||||
const history = [{ id: "m1", parts: [toolPart("call-1")] }];
|
||||
const seen = new Set<string>();
|
||||
pendingWatchIntents(history, seen);
|
||||
|
||||
expect(pendingWatchIntents(history, seen)).toEqual([]);
|
||||
expect(
|
||||
pendingWatchIntents([...history, { id: "m2", parts: [toolPart("call-2")] }], seen)
|
||||
).toEqual([{ kind: "watch", spec }]);
|
||||
});
|
||||
|
||||
it("doesn't confuse a navigate result for a watch", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "m1",
|
||||
parts: [
|
||||
{
|
||||
type: "tool-navigate_to",
|
||||
state: "output-available",
|
||||
toolCallId: "call-1",
|
||||
output: { intent: { kind: "navigate", target: "trigger://p/e/run/run_abc" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(pendingWatchIntents(messages, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,3 +40,11 @@ export function pendingNavigateIntents(
|
||||
): Array<Extract<AgentIntent, { kind: "navigate" }>> {
|
||||
return pendingToolIntents(messages, seen, "tool-navigate_to", "navigate");
|
||||
}
|
||||
|
||||
// `schedule_watch` only proposes: the panel creates the watch.
|
||||
export function pendingWatchIntents(
|
||||
messages: ReadonlyArray<ToolMessage>,
|
||||
seen: Set<string>
|
||||
): Array<Extract<AgentIntent, { kind: "watch" }>> {
|
||||
return pendingToolIntents(messages, seen, "tool-schedule_watch", "watch");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextPendingTurnChatId } from "./pending-turn";
|
||||
import { shouldPollWakeFeed } from "./watch-activity";
|
||||
|
||||
/**
|
||||
* The launcher dot only appears if the wake poll is running when the answer lands. A turn
|
||||
* started in the panel has to keep the poll alive across a close, and let go of it once the
|
||||
* answer has been seen.
|
||||
*/
|
||||
describe("nextPendingTurnChatId", () => {
|
||||
it("latches onto the chat whose turn started", () => {
|
||||
expect(nextPendingTurnChatId(null, { chatId: "chat_a", active: true })).toBe("chat_a");
|
||||
});
|
||||
|
||||
it("holds while a newer turn takes over", () => {
|
||||
const afterA = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
|
||||
expect(nextPendingTurnChatId(afterA, { chatId: "chat_b", active: true })).toBe("chat_b");
|
||||
});
|
||||
|
||||
it("lets go once that chat's turn is no longer running", () => {
|
||||
const pending = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
|
||||
expect(nextPendingTurnChatId(pending, { chatId: "chat_a", active: false })).toBe(null);
|
||||
});
|
||||
|
||||
it("keeps waiting when a different chat goes quiet", () => {
|
||||
const pending = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
|
||||
expect(nextPendingTurnChatId(pending, { chatId: "chat_b", active: false })).toBe("chat_a");
|
||||
});
|
||||
|
||||
it("stays clear when nothing is pending", () => {
|
||||
expect(nextPendingTurnChatId(null, { chatId: "chat_a", active: false })).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a turn started behind a closed panel", () => {
|
||||
// The page load knew of nothing: no wake, no watch, no unread work. Only the turn can
|
||||
// start the poll.
|
||||
const quietPageLoad = {
|
||||
serverUnreadWakes: 0,
|
||||
serverHasActiveWatches: false,
|
||||
serverUnreadWork: 0,
|
||||
organizationId: "org_quiet",
|
||||
};
|
||||
|
||||
it("keeps the poll running until the answer is seen", () => {
|
||||
expect(shouldPollWakeFeed({ ...quietPageLoad, turnInFlight: false })).toBe(false);
|
||||
|
||||
// Asked a question, then closed the panel: the panel reports no end, so the latch holds.
|
||||
const pending = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
|
||||
expect(shouldPollWakeFeed({ ...quietPageLoad, turnInFlight: pending !== null })).toBe(true);
|
||||
|
||||
// Re-opened the chat with the turn already over.
|
||||
const seen = nextPendingTurnChatId(pending, { chatId: "chat_a", active: false });
|
||||
expect(shouldPollWakeFeed({ ...quietPageLoad, turnInFlight: seen !== null })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Which chat this tab is still waiting on. A turn started here can finish after the panel
|
||||
* closes — the case the launcher dot exists for — so the wake poll has to keep running until
|
||||
* the answer has been seen. The panel reports turn activity while it is mounted; closing it
|
||||
* reports nothing, which is what leaves the latch set.
|
||||
*/
|
||||
|
||||
/** `active` is true while a turn is running in `chatId`, false once it is not. */
|
||||
export function nextPendingTurnChatId(
|
||||
current: string | null,
|
||||
event: { chatId: string; active: boolean }
|
||||
): string | null {
|
||||
if (event.active) return event.chatId;
|
||||
// Only the chat we are waiting on clears the latch; another chat going quiet says nothing
|
||||
// about this one.
|
||||
return current === event.chatId ? null : current;
|
||||
}
|
||||
@@ -315,6 +315,13 @@ export function ReportNoteBlock({ label, children }: { label: string; children:
|
||||
// and `note` is prose for an option stated rather than offered.
|
||||
export { reportFooterStyle, type ReportFooterStyle };
|
||||
|
||||
/**
|
||||
* The recovery-watch offer. No report emits it; the card adds it. Two codes
|
||||
* because it is phrased differently when it is the only thing on offer.
|
||||
*/
|
||||
export const FOOTER_WATCH_CODE = "watch_recovery";
|
||||
export const FOOTER_WATCH_ONLY_CODE = "watch_recovery_only";
|
||||
|
||||
/** A dimmed line that accompanies a row entry. */
|
||||
const FOOTER_NOTE_LINES: Record<string, string> = {
|
||||
check_control_plane: "There's nothing to fix on your side.",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sendRequestOutcome } from "./send-request";
|
||||
|
||||
describe("sendRequestOutcome", () => {
|
||||
it("sends a request the chat can take", () => {
|
||||
expect(sendRequestOutcome({ requestSeq: 1, consumedSeq: undefined, canSend: true })).toBe(
|
||||
"send"
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a request it has already sent", () => {
|
||||
expect(sendRequestOutcome({ requestSeq: 1, consumedSeq: 1, canSend: true })).toBe("skip");
|
||||
});
|
||||
|
||||
it("skips when nothing was asked for", () => {
|
||||
expect(sendRequestOutcome({ requestSeq: undefined, consumedSeq: 3, canSend: true })).toBe(
|
||||
"skip"
|
||||
);
|
||||
});
|
||||
|
||||
it("holds a request the chat can't take yet, and sends it once it can", () => {
|
||||
expect(sendRequestOutcome({ requestSeq: 2, consumedSeq: 1, canSend: false })).toBe("hold");
|
||||
// The held click is still the same request: nothing consumed it while it waited.
|
||||
expect(sendRequestOutcome({ requestSeq: 2, consumedSeq: 1, canSend: true })).toBe("send");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/** What to do with a prompt the panel asked the chat to send. */
|
||||
export type SendRequestOutcome = "send" | "hold" | "skip";
|
||||
|
||||
/**
|
||||
* A click on a suggested prompt is consumed only once it has actually been sent. Marking it
|
||||
* consumed first loses the click whenever the chat can't take it yet — the message cap being
|
||||
* the case that has no other way back in — so an unsendable request is held for the next render.
|
||||
*/
|
||||
export function sendRequestOutcome(params: {
|
||||
requestSeq: number | undefined;
|
||||
consumedSeq: number | undefined;
|
||||
canSend: boolean;
|
||||
}): SendRequestOutcome {
|
||||
if (params.requestSeq === undefined) return "skip";
|
||||
if (params.requestSeq === params.consumedSeq) return "skip";
|
||||
return params.canSend ? "send" : "hold";
|
||||
}
|
||||
@@ -206,12 +206,18 @@ describe("queueAgentPageContext", () => {
|
||||
const context = queueAgentPageContext(queueLoaderData());
|
||||
|
||||
expect(context).toEqual({
|
||||
page: { kind: "queue", name: "black-friday", health: "ok" },
|
||||
page: { kind: "queue", name: "black-friday", health: "ok", paused: false },
|
||||
signals: [],
|
||||
});
|
||||
expect(agentPageContextSchema.safeParse(context).success).toBe(true);
|
||||
});
|
||||
|
||||
// A watch the agent proposes off this context is validated against the stored name.
|
||||
it("names a task queue by its stored name, prefix and all", () => {
|
||||
const context = queueAgentPageContext(queueLoaderData({ type: "task", name: "send-receipt" }));
|
||||
expect(context?.page).toMatchObject({ kind: "queue", name: "task/send-receipt" });
|
||||
});
|
||||
|
||||
it("emits no saturation signal when the queue is idle under its limit", () => {
|
||||
const context = queueAgentPageContext(queueLoaderData({ running: 10, queued: 0 }));
|
||||
expect(context?.signals).toEqual([]);
|
||||
@@ -244,6 +250,16 @@ describe("queueAgentPageContext", () => {
|
||||
expect(context?.signals).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers no watch on a paused queue, even when it is at capacity", () => {
|
||||
// Paused and saturated at once: nothing will drain or grow until it is resumed, so a
|
||||
// watch would promise an answer that can't come.
|
||||
const context = queueAgentPageContext(
|
||||
queueLoaderData({ paused: true, running: 10, queued: 40, concurrencyLimit: 10 })
|
||||
);
|
||||
|
||||
expect(context?.signals).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits nothing for an unlimited queue, however deep the backlog", () => {
|
||||
const context = queueAgentPageContext(
|
||||
queueLoaderData({ concurrencyLimit: null, running: 99, queued: 99 })
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type { AgentPageContext, AgentPageSignal } from "@internal/dashboard-agent-contracts";
|
||||
import { z } from "zod";
|
||||
import { storedQueueName } from "~/components/queues/queue-name";
|
||||
import { isQueueAtCapacity, OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
|
||||
|
||||
export const FRESH_FAILURE_WINDOW_MS = 30 * 60_000;
|
||||
@@ -163,6 +164,7 @@ export const QUEUE_OLDEST_WAIT_WARNING_MS = OLDEST_WAIT_WARNING_MS;
|
||||
const queueLoaderDataSchema = z.object({
|
||||
queue: z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
paused: z.boolean().nullish(),
|
||||
running: z.number(),
|
||||
queued: z.number(),
|
||||
@@ -200,7 +202,7 @@ export function queueAgentPageContext(data: unknown): AgentPageContext | undefin
|
||||
const parsed = queueLoaderDataSchema.safeParse(data);
|
||||
if (!parsed.success) return undefined;
|
||||
|
||||
const { name, paused, running, queued, concurrencyLimit } = parsed.data.queue;
|
||||
const { name, type, paused, running, queued, concurrencyLimit } = parsed.data.queue;
|
||||
const { environmentConcurrencyLimit, oldestQueuedAt, loadedAt, ckBreakdown } = parsed.data;
|
||||
const limit = concurrencyLimit ?? environmentConcurrencyLimit ?? null;
|
||||
const atCapacity = isQueueAtCapacity({ running, queued, limit });
|
||||
@@ -211,12 +213,17 @@ export function queueAgentPageContext(data: unknown): AgentPageContext | undefin
|
||||
const health = atCapacity ? "crit" : paused || queued > 0 || waitingTooLong ? "warn" : "ok";
|
||||
|
||||
const signals: AgentPageSignal[] = [];
|
||||
if (atCapacity) {
|
||||
// Nothing to watch on a paused queue: it can neither drain nor grow until it is resumed.
|
||||
if (atCapacity && !paused) {
|
||||
// A backlog at least as deep as the limit won't clear this cycle.
|
||||
signals.push({ kind: "concurrency_saturation", severity: queued >= limit! ? "crit" : "warn" });
|
||||
}
|
||||
|
||||
return { page: { kind: "queue", name, health }, signals };
|
||||
// The stored name, not the display one: a watch the agent proposes has to validate against it.
|
||||
return {
|
||||
page: { kind: "queue", name: storedQueueName({ type, name }), health, paused: Boolean(paused) },
|
||||
signals,
|
||||
};
|
||||
}
|
||||
|
||||
export function deploymentsAgentPageContext(): AgentPageContext {
|
||||
|
||||
@@ -97,6 +97,11 @@ export function pageSlotPrompts(page: AgentPage): PageSlotPrompts {
|
||||
"Why does this keep happening?",
|
||||
"Investigate this error — why does it keep coming back, and which runs are affected?"
|
||||
),
|
||||
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",
|
||||
"Find similar failures",
|
||||
@@ -117,14 +122,23 @@ export function pageSlotPrompts(page: AgentPage): PageSlotPrompts {
|
||||
|
||||
case "queue":
|
||||
return {
|
||||
// A paused queue is backed up because someone paused it, and nothing it could be
|
||||
// watched for will happen until they resume it — so neither chip is offered.
|
||||
investigate:
|
||||
page.health === "warn" || page.health === "crit"
|
||||
!page.paused && (page.health === "warn" || page.health === "crit")
|
||||
? def(
|
||||
"queue-backlog-cause",
|
||||
"Why is this queue backed up?",
|
||||
queueBacklogPrompt(page.name)
|
||||
)
|
||||
: undefined,
|
||||
watch: page.paused
|
||||
? undefined
|
||||
: def(
|
||||
"queue-watch-drain",
|
||||
"Tell me when the backlog drains",
|
||||
`Watch the ${page.name} queue and tell me when the backlog drains.`
|
||||
),
|
||||
status: def(
|
||||
"queue-backlog",
|
||||
"How big is the backlog?",
|
||||
|
||||
@@ -25,12 +25,13 @@ export 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", "status", "explain", "docs"] as const;
|
||||
|
||||
export type PromptSlot = (typeof PROMPT_SLOTS)[number];
|
||||
|
||||
export type PageSlotPrompts = {
|
||||
investigate?: SuggestedPrompt;
|
||||
watch?: SuggestedPrompt;
|
||||
status?: SuggestedPrompt;
|
||||
explain: SuggestedPrompt;
|
||||
docs: SuggestedPrompt;
|
||||
|
||||
@@ -25,27 +25,33 @@ const docsId = (key: keyof typeof demoPageContexts) =>
|
||||
pageSlotPrompts(demoPageContexts[key].page).docs.id;
|
||||
|
||||
describe("resolveSuggestedPrompts", () => {
|
||||
it("fills every slot the page has, given a promoted chip, signals and defaults", () => {
|
||||
it("fills all five slots when the page has a promoted chip, signals and defaults", () => {
|
||||
const prompts = resolveSuggestedPrompts(demoPageContexts.error, { promoted, now: NOW });
|
||||
|
||||
expect(prompts).toHaveLength(4);
|
||||
expect(prompts).toHaveLength(5);
|
||||
expect(ids(prompts)).toEqual([
|
||||
promoted.id,
|
||||
"sp:fresh-failure",
|
||||
"sp:error-watch-recurrence",
|
||||
"sp:error-similar",
|
||||
docsId("error"),
|
||||
]);
|
||||
expect(prompts[0]?.source).toBe("promoted");
|
||||
});
|
||||
|
||||
it("drops a slot when nothing is promoted", () => {
|
||||
it("drops to four slots when nothing is promoted", () => {
|
||||
const prompts = resolveSuggestedPrompts(demoPageContexts.error, { now: NOW });
|
||||
|
||||
expect(prompts).toHaveLength(3);
|
||||
expect(ids(prompts)).toEqual(["sp:fresh-failure", "sp:error-similar", docsId("error")]);
|
||||
expect(prompts).toHaveLength(4);
|
||||
expect(ids(prompts)).toEqual([
|
||||
"sp:fresh-failure",
|
||||
"sp:error-watch-recurrence",
|
||||
"sp:error-similar",
|
||||
docsId("error"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows explain + docs only when no investigate applies", () => {
|
||||
it("shows explain + docs only when no investigate or watch applies", () => {
|
||||
const prompts = resolveSuggestedPrompts(demoPageContexts.deployment, { now: NOW });
|
||||
|
||||
expect(ids(prompts)).toEqual(ids(pageDefaultPrompts(demoPageContexts.deployment.page)));
|
||||
@@ -73,7 +79,7 @@ describe("resolveSuggestedPrompts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("orders promoted, then investigate, then status, then explain", () => {
|
||||
it("orders promoted, then investigate, then watch, then explain", () => {
|
||||
const context = {
|
||||
...demoPageContexts.queue,
|
||||
signals: [...demoPageContexts.queue.signals, demoFreshFailureSignal],
|
||||
@@ -84,7 +90,8 @@ describe("resolveSuggestedPrompts", () => {
|
||||
expect(ids(prompts)).toEqual([
|
||||
promoted.id,
|
||||
"sp:fresh-failure",
|
||||
"sp:queue-backlog",
|
||||
// waiting_run beats concurrency_saturation for the watch slot.
|
||||
"sp:waiting-run",
|
||||
"sp:queue-state",
|
||||
docsId("queue"),
|
||||
]);
|
||||
@@ -122,10 +129,10 @@ describe("resolveSuggestedPrompts", () => {
|
||||
const full = resolveSuggestedPrompts(demoPageContexts.error, { now: NOW });
|
||||
const dismissed = resolveSuggestedPrompts(demoPageContexts.error, {
|
||||
now: NOW,
|
||||
dismissedIds: ["sp:error-similar"],
|
||||
dismissedIds: ["sp:error-watch-recurrence"],
|
||||
});
|
||||
|
||||
expect(ids(dismissed)).not.toContain("sp:error-similar");
|
||||
expect(ids(dismissed)).not.toContain("sp:error-watch-recurrence");
|
||||
expect(dismissed).toHaveLength(full.length - 1);
|
||||
expect(dismissed.at(-1)?.id).toBe(docsId("error"));
|
||||
});
|
||||
@@ -161,7 +168,12 @@ describe("resolveSuggestedPrompts", () => {
|
||||
expect(failure?.prompt).toContain("12m ago");
|
||||
});
|
||||
|
||||
it("words the slow-run chip for its slot", () => {
|
||||
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("Tell me when this run starts");
|
||||
expect(waitingChip?.prompt).toContain("queue");
|
||||
|
||||
const slow = resolveSuggestedPrompts(demoPageContexts.slowRun, { now: NOW });
|
||||
expect(slow[0]?.label).toBe("~7.8x slower than usual");
|
||||
});
|
||||
@@ -223,6 +235,17 @@ describe("pageSlotPrompts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a paused queue neither chip, however unhealthy it looks", () => {
|
||||
// Paused reads as `warn`, so without the guard the backlog chips would both appear —
|
||||
// asking why a queue someone paused is backed up, and offering to watch it drain.
|
||||
const slots = pageSlotPrompts({ kind: "queue", name: "emails", health: "warn", paused: true });
|
||||
|
||||
expect(slots.investigate).toBeUndefined();
|
||||
expect(slots.watch).toBeUndefined();
|
||||
// The page is still explainable; only the two backlog asks are withheld.
|
||||
expect(slots.explain).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers a deployment investigate chip only for a deploy that didn't land", () => {
|
||||
expect(pageSlotPrompts({ kind: "deployment", version: "1.0" }).investigate).toBeUndefined();
|
||||
expect(
|
||||
|
||||
@@ -68,7 +68,7 @@ export function resolveSuggestedPromptsBySlot(
|
||||
}
|
||||
|
||||
// Over the cap, optional slots yield in this order; promoted, explain and docs never yield.
|
||||
const yieldOrder: ResolvedPromptSlot[] = ["status", "investigate"];
|
||||
const yieldOrder: ResolvedPromptSlot[] = ["status", "watch", "investigate"];
|
||||
let trimmed = resolved;
|
||||
for (const slot of yieldOrder) {
|
||||
if (trimmed.length <= SUGGESTED_PROMPT_CAP) break;
|
||||
|
||||
@@ -10,14 +10,20 @@ import type {
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
import { ctx, type PromptSlot } from "./prompt-chips";
|
||||
|
||||
/** A kind with no entry produces no chip. */
|
||||
export const SIGNAL_SLOT: Partial<Record<AgentPageSignalKind, PromptSlot>> = {
|
||||
export const SIGNAL_SLOT: Record<AgentPageSignalKind, PromptSlot> = {
|
||||
fresh_failure: "investigate",
|
||||
slow_run: "investigate",
|
||||
waiting_run: "watch",
|
||||
concurrency_saturation: "watch",
|
||||
};
|
||||
|
||||
/** Signal precedence within a slot. */
|
||||
export const SIGNAL_PRIORITY: AgentPageSignalKind[] = ["fresh_failure", "slow_run"];
|
||||
/** Signal precedence within a slot. Mirrors `demoSignalsByPriority` in the fixtures. */
|
||||
export const SIGNAL_PRIORITY: AgentPageSignalKind[] = [
|
||||
"fresh_failure",
|
||||
"waiting_run",
|
||||
"slow_run",
|
||||
"concurrency_saturation",
|
||||
];
|
||||
|
||||
/** "3m", "2h", "4d". */
|
||||
export function formatAgo(ms: number): string {
|
||||
@@ -47,6 +53,15 @@ export function promptForSignal(signal: AgentPageSignal, now: number): Suggested
|
||||
);
|
||||
}
|
||||
|
||||
case "waiting_run":
|
||||
return ctx(
|
||||
"waiting-run",
|
||||
"Tell me when this run starts",
|
||||
signal.queue
|
||||
? `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": {
|
||||
if (signal.baselineP95Ms <= 0) return undefined;
|
||||
const factor = formatMultiplier(signal.durationMs / signal.baselineP95Ms);
|
||||
@@ -56,6 +71,13 @@ export function promptForSignal(signal: AgentPageSignal, now: number): Suggested
|
||||
`${signal.runId} is running ~${factor} slower than this task's usual p95. Investigate why.`
|
||||
);
|
||||
}
|
||||
|
||||
case "concurrency_saturation":
|
||||
return ctx(
|
||||
"concurrency-saturation",
|
||||
"Tell me when the backlog drains",
|
||||
"Concurrency is saturated right now. Watch it and tell me when the backlog drains."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,18 +101,17 @@ export function contextualPromptsBySlot(
|
||||
): Record<PromptSlot, SuggestedPrompt[]> {
|
||||
const bySlot: Record<PromptSlot, SuggestedPrompt[]> = {
|
||||
investigate: [],
|
||||
watch: [],
|
||||
status: [],
|
||||
explain: [],
|
||||
docs: [],
|
||||
};
|
||||
|
||||
for (const kind of SIGNAL_PRIORITY) {
|
||||
const slot = SIGNAL_SLOT[kind];
|
||||
if (!slot) continue;
|
||||
for (const signal of context.signals) {
|
||||
if (signal.kind !== kind) continue;
|
||||
const prompt = promptForSignal(signal, now);
|
||||
if (prompt) bySlot[slot].push(prompt);
|
||||
if (prompt) bySlot[SIGNAL_SLOT[kind]].push(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const TOOL_LABELS: Record<string, string> = {
|
||||
search_docs: "Searching the docs",
|
||||
get_current_page: "Reading the current page",
|
||||
navigate_to: "Opening the page",
|
||||
schedule_watch: "Filling in a watch",
|
||||
list_alerts: "Listing alerts",
|
||||
create_alert: "Creating an alert",
|
||||
delete_alert: "Deleting an alert",
|
||||
|
||||
@@ -7,7 +7,7 @@ const failure = { id: "turn-error:0" };
|
||||
describe("the failed-turn record", () => {
|
||||
it("recognises the agent's failure message id", () => {
|
||||
expect(isTurnErrorMessageId("turn-error:3")).toBe(true);
|
||||
expect(isTurnErrorMessageId("msg_1")).toBe(false);
|
||||
expect(isTurnErrorMessageId("wake:watch:watch_1:fired")).toBe(false);
|
||||
expect(isTurnErrorMessageId(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* A failed turn is recorded in the transcript by the agent, under the message id
|
||||
* `turn-error:{turn}`. The prefix is the transport convention, recognised here so
|
||||
* the panel can tell a stored failure record apart from an ordinary answer.
|
||||
* `turn-error:{turn}`. Same arrangement as a wake's `wake:watch:…` id: the prefix
|
||||
* is the transport convention, recognised here so the panel can tell a stored
|
||||
* failure record apart from an ordinary answer.
|
||||
*
|
||||
* Live, a failure arrives as the stream's error chunk and `useChat` surfaces it as
|
||||
* the retry callout. The stored record is what a reload reads. Both must never show
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navigateIntentApplies, takeNavigateIntent } from "./turn-navigation";
|
||||
|
||||
const runs = "/orgs/acme/projects/api/env/prod/runs";
|
||||
const queues = "/orgs/acme/projects/api/env/prod/queues";
|
||||
|
||||
describe("navigateIntentApplies", () => {
|
||||
it("navigates when the user is still where the turn was asked for", () => {
|
||||
expect(navigateIntentApplies({ startedPath: runs, currentPath: runs })).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the navigation once the user has walked to another screen", () => {
|
||||
expect(navigateIntentApplies({ startedPath: runs, currentPath: queues })).toBe(false);
|
||||
});
|
||||
|
||||
it("drops it when this tab never saw the turn start", () => {
|
||||
// A resumed turn: nothing here knows the page it was asked on.
|
||||
expect(navigateIntentApplies({ startedPath: null, currentPath: runs })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeNavigateIntent", () => {
|
||||
const target = "trigger://proj_abc/env_123/run/run_abc";
|
||||
|
||||
function messages() {
|
||||
return [
|
||||
{
|
||||
id: "msg_1",
|
||||
parts: [
|
||||
{
|
||||
type: "tool-navigate_to",
|
||||
state: "output-available",
|
||||
toolCallId: "call_1",
|
||||
output: { intent: { kind: "navigate", target } },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("takes the navigation on the page the turn was asked for", () => {
|
||||
const taken = takeNavigateIntent({
|
||||
messages: messages(),
|
||||
handled: new Set(),
|
||||
startedPath: runs,
|
||||
currentPath: runs,
|
||||
});
|
||||
expect(taken).toMatchObject({ kind: "navigate", target });
|
||||
});
|
||||
|
||||
it("takes nothing once the user has walked to another screen", () => {
|
||||
expect(
|
||||
takeNavigateIntent({
|
||||
messages: messages(),
|
||||
handled: new Set(),
|
||||
startedPath: runs,
|
||||
currentPath: queues,
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// The property the panel depends on: a commit that drops a navigation still consumes it, so
|
||||
// walking back to the page it was asked on does not make it fire late.
|
||||
it("marks a dropped navigation handled, so a later commit cannot fire it", () => {
|
||||
const handled = new Set<string>();
|
||||
const parts = messages();
|
||||
|
||||
expect(
|
||||
takeNavigateIntent({ messages: parts, handled, startedPath: runs, currentPath: queues })
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
takeNavigateIntent({ messages: parts, handled, startedPath: runs, currentPath: runs })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Structural guards, not behavioural proof: whether the started-at path is still right when the
|
||||
* intent lands depends on effect order and on nothing clearing it, which these assertions pin
|
||||
* down without rendering anything.
|
||||
*/
|
||||
describe("the chat scopes a turn's navigation to the page it started on", () => {
|
||||
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
|
||||
|
||||
it("gates the navigate intent on the shared rule", () => {
|
||||
expect(chat).toContain("takeNavigateIntent({");
|
||||
expect(chat).toContain("startedPath: turnStartedPathRef.current");
|
||||
expect(chat).not.toContain("pendingNavigateIntents(messages");
|
||||
});
|
||||
|
||||
// Structural: the webapp has no DOM test environment, so the wiring is read off the source.
|
||||
// Going in flight cannot mean "started here" — a resumed turn goes in flight too, and stamping
|
||||
// on status handed it the current path and let it navigate.
|
||||
it("does not infer the path from the turn going in flight", () => {
|
||||
expect(chat).not.toContain("turnWasInFlight");
|
||||
expect(chat).not.toMatch(
|
||||
/status === "submitted"[\s\S]{0,160}turnStartedPathRef\.current = renderedPathRef/
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps the path at every send, so a turn this tab only resumed leaves it null", () => {
|
||||
const lines = chat.split("\n");
|
||||
const sends = lines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(({ line }) => /void (sendMessage|regenerate)\(/.test(line));
|
||||
|
||||
expect(sends.length).toBeGreaterThan(0);
|
||||
for (const { line, index } of sends) {
|
||||
const preceding = lines.slice(Math.max(0, index - 5), index).join("\n");
|
||||
expect(
|
||||
preceding.includes("turnStartedPathRef.current = renderedPathRef.current"),
|
||||
`no path stamped before: ${line.trim()}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("never clears the path on settle, which can share a commit with the intent", () => {
|
||||
expect(chat).not.toMatch(/turnStartedPathRef\.current = null/);
|
||||
});
|
||||
|
||||
it("records the path before the intent effect reads it", () => {
|
||||
expect(chat.indexOf("turnStartedPathRef.current = renderedPathRef.current")).toBeLessThan(
|
||||
chat.indexOf("takeNavigateIntent({")
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the persistent handled-set in, so drops are recorded across commits", () => {
|
||||
expect(chat).toMatch(/handled:\s*navigatedRef\.current!/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { pendingNavigateIntents } from "./pending-intents";
|
||||
|
||||
/**
|
||||
* The panel follows the user around the dashboard, so a turn can outlive the page it was asked
|
||||
* on. Its navigation applies only there: someone who has since walked to another screen keeps
|
||||
* the screen they chose, and the answer's own button is still theirs to click.
|
||||
*/
|
||||
export function navigateIntentApplies(paths: {
|
||||
/** Null when this tab never saw the turn start, so it cannot claim the user is still there. */
|
||||
startedPath: string | null;
|
||||
currentPath: string;
|
||||
}): boolean {
|
||||
return paths.startedPath === paths.currentPath;
|
||||
}
|
||||
|
||||
type NavigateIntent = ReturnType<typeof pendingNavigateIntents>[number];
|
||||
|
||||
/**
|
||||
* The navigation to take on this commit, if any. Every intent is marked handled whether or not
|
||||
* it applies, so one dropped here cannot fire on a later commit.
|
||||
*/
|
||||
export function takeNavigateIntent(args: {
|
||||
messages: Parameters<typeof pendingNavigateIntents>[0];
|
||||
handled: Set<string>;
|
||||
startedPath: string | null;
|
||||
currentPath: string;
|
||||
}): NavigateIntent | undefined {
|
||||
const target = pendingNavigateIntents(args.messages, args.handled).at(-1);
|
||||
if (!target) return undefined;
|
||||
return navigateIntentApplies({ startedPath: args.startedPath, currentPath: args.currentPath })
|
||||
? target
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { teardownCancelsTurn, unmountTeardown } from "./turn-teardown";
|
||||
|
||||
describe("teardownCancelsTurn", () => {
|
||||
it("cancels when the user clicks Stop", () => {
|
||||
expect(teardownCancelsTurn("stop-clicked")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the turn when the panel closes", () => {
|
||||
expect(teardownCancelsTurn("panel-closed")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the turn when the panel changes chat", () => {
|
||||
expect(teardownCancelsTurn("chat-switched")).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels when the user has left the page", () => {
|
||||
expect(teardownCancelsTurn("navigated-away")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unmountTeardown", () => {
|
||||
const path = "/orgs/acme/projects/api/env/prod/runs";
|
||||
|
||||
it("reads an unmount on the same path as the panel closing", () => {
|
||||
expect(unmountTeardown({ renderedPath: path, livePath: path })).toBe("panel-closed");
|
||||
});
|
||||
|
||||
it("reads an unmount after the URL moved as a navigation", () => {
|
||||
expect(unmountTeardown({ renderedPath: path, livePath: "/orgs/acme/settings" })).toBe(
|
||||
"navigated-away"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Structural guards, not behavioural proof: the wiring depends on when React runs the cleanup
|
||||
* relative to the router, which these assertions pin down without rendering anything.
|
||||
*/
|
||||
describe("the chat cancels its turn only on the teardowns that say so", () => {
|
||||
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
|
||||
|
||||
it("decides through the shared rule rather than unmounting straight into a stop", () => {
|
||||
expect(chat).toContain("teardownCancelsTurn(");
|
||||
expect(chat).toContain("unmountTeardown({");
|
||||
});
|
||||
|
||||
it("compares the last rendered path against the live one", () => {
|
||||
expect(chat).toContain("renderedPath: renderedPathRef.current");
|
||||
expect(chat).toContain("livePath: window.location.pathname");
|
||||
});
|
||||
|
||||
// Where "filtering a page is not leaving it" actually lives: both sides are pathnames, so a
|
||||
// query string never reaches the comparison. Widen either side and a filter change reads as a
|
||||
// navigation, cancelling the turn.
|
||||
it("tracks the rendered path without its query string", () => {
|
||||
expect(chat).toContain("useRef(location.pathname)");
|
||||
expect(chat).toContain("renderedPathRef.current = location.pathname;");
|
||||
expect(chat).not.toContain("location.search");
|
||||
});
|
||||
|
||||
it("runs the cleanup once, not on every path change", () => {
|
||||
const teardown = chat.slice(chat.indexOf("const teardownRef"));
|
||||
expect(teardown).toMatch(
|
||||
/useEffect\(\s*\(\)\s*=>\s*\(\)\s*=>\s*teardownRef\.current\(\),\s*\[\]\)/
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels nothing when no turn is in flight", () => {
|
||||
expect(chat).toContain('if (status !== "streaming" && status !== "submitted") return;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Why a chat with a turn in flight is going away. */
|
||||
export type TurnTeardown = "stop-clicked" | "panel-closed" | "chat-switched" | "navigated-away";
|
||||
|
||||
/**
|
||||
* A turn that finishes behind a closed panel is what the launcher dot exists for, so only a
|
||||
* deliberate stop and leaving the page end it early.
|
||||
*/
|
||||
export function teardownCancelsTurn(reason: TurnTeardown): boolean {
|
||||
return reason === "stop-clicked" || reason === "navigated-away";
|
||||
}
|
||||
|
||||
/**
|
||||
* The three unmounts look identical from inside React. Only a navigation has already moved the
|
||||
* URL by the time the cleanup runs; the other two keep the turn, so they share one branch.
|
||||
*/
|
||||
export function unmountTeardown(paths: { renderedPath: string; livePath: string }): TurnTeardown {
|
||||
return paths.renderedPath === paths.livePath ? "panel-closed" : "navigated-away";
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
markChatListRead,
|
||||
nextVisibleChat,
|
||||
settleReadChats,
|
||||
unreadWorkCount,
|
||||
} from "./unread-counts";
|
||||
|
||||
const list = () => [
|
||||
{ id: "chat_a", hasUnreadWake: true, hasUnreadWork: true },
|
||||
{ id: "chat_b", hasUnreadWork: true },
|
||||
{ id: "chat_c" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The dot counts chats, not visits. Reading a chat settles it in the list, and the list is
|
||||
* what the count is derived from — so one visit, or ten, subtracts the same one chat.
|
||||
*/
|
||||
describe("the work count is derived from the list", () => {
|
||||
it("counts every chat still holding unseen work", () => {
|
||||
expect(unreadWorkCount(list())).toBe(2);
|
||||
});
|
||||
|
||||
it("subtracts a read chat once, however many times it is read", () => {
|
||||
const once = markChatListRead(list(), "chat_a");
|
||||
expect(unreadWorkCount(once)).toBe(1);
|
||||
// The read effect fires on entry and again on cleanup, and again on every revisit.
|
||||
const again = markChatListRead(markChatListRead(once, "chat_a"), "chat_a");
|
||||
expect(unreadWorkCount(again)).toBe(1);
|
||||
});
|
||||
|
||||
it("reaches zero only when every chat has been read", () => {
|
||||
const all = ["chat_a", "chat_b", "chat_c"].reduce(markChatListRead, list());
|
||||
expect(unreadWorkCount(all)).toBe(0);
|
||||
});
|
||||
|
||||
it("settles the wake alongside the work, so the row stops looking unread", () => {
|
||||
const read = markChatListRead(list(), "chat_a");
|
||||
expect(read[0]).toEqual({ id: "chat_a", hasUnreadWake: false, hasUnreadWork: false });
|
||||
// Every other chat is left exactly as it was.
|
||||
expect(read.slice(1)).toEqual(list().slice(1));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A wake in the chat on screen must not light the dot — but once the panel has let go of that
|
||||
* chat, its wakes have to reach the dot again.
|
||||
*/
|
||||
describe("nextVisibleChat", () => {
|
||||
it("holds the chat while it is on screen", () => {
|
||||
expect(nextVisibleChat("chat_a", { leaving: false })).toBe("chat_a");
|
||||
});
|
||||
|
||||
it("lets go on the way out instead of restoring it", () => {
|
||||
expect(nextVisibleChat("chat_a", { leaving: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A turn landing in the chat on screen must not light the dot — and the chat is left out of the
|
||||
* count rather than subtracted off it afterwards, so a chat holding nothing is never subtracted.
|
||||
*/
|
||||
describe("the chat on screen", () => {
|
||||
it("is left out, however much work lands in it", () => {
|
||||
expect(unreadWorkCount(list(), "chat_a")).toBe(1);
|
||||
});
|
||||
|
||||
it("takes nothing off the count when it holds no unseen work", () => {
|
||||
expect(unreadWorkCount(list(), "chat_c")).toBe(2);
|
||||
});
|
||||
|
||||
it("counts every chat when there is none on screen", () => {
|
||||
expect(unreadWorkCount(list(), null)).toBe(2);
|
||||
expect(unreadWorkCount(list(), undefined)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The list is refreshed after every turn, and the server's lastReadAt trails the turn that just
|
||||
* landed — so the chat being read has to settle on its own, not wait for the next read to land.
|
||||
*/
|
||||
describe("settleReadChats", () => {
|
||||
it("settles the chat on screen, however fresh the turn that just landed in it", () => {
|
||||
const settled = settleReadChats(list(), new Set(), "chat_a");
|
||||
expect(settled[0]).toEqual({ id: "chat_a", hasUnreadWake: false, hasUnreadWork: false });
|
||||
expect(settled.slice(1)).toEqual(list().slice(1));
|
||||
});
|
||||
|
||||
it("settles the chats just read", () => {
|
||||
const settled = settleReadChats(list(), new Set(["chat_b"]), null);
|
||||
expect(settled[1]).toEqual({ id: "chat_b", hasUnreadWake: false, hasUnreadWork: false });
|
||||
expect(unreadWorkCount(settled)).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves every other chat exactly as the server reported it", () => {
|
||||
expect(settleReadChats(list(), new Set(), null)).toEqual(list());
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the panel and the layout actually do with it", () => {
|
||||
const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8");
|
||||
const layout = readFileSync(new URL("./DashboardAgent.tsx", import.meta.url), "utf8");
|
||||
|
||||
it("reports the count from the list, and only from the list", () => {
|
||||
expect(panel).toContain("onUnreadWorkChange?.(unreadWorkCount(chats, active?.chatId));");
|
||||
expect(panel).not.toContain("settled.filter((chat) => chat.hasUnreadWork).length");
|
||||
expect(layout).not.toContain("setUnreadWork((count) => Math.max(0, count - 1))");
|
||||
});
|
||||
|
||||
it("tells the read effect's cleanup that it is leaving", () => {
|
||||
expect(panel).toContain("onChatRead?.(chatId, { leaving: false });");
|
||||
expect(panel).toContain("onChatRead?.(chatId, { leaving: true });");
|
||||
expect(layout).toContain("visibleChat.current = nextVisibleChat(chatId, options);");
|
||||
});
|
||||
|
||||
/**
|
||||
* Structural: the reload is memoised without `active`, so the chat on screen has to reach the
|
||||
* settle through a ref — the closure's copy is whatever it was when the reload was created.
|
||||
*/
|
||||
it("settles the refreshed list against the chat on screen, read from a ref", () => {
|
||||
expect(panel).toContain("settleReadChats(chats, read, visibleChatId.current)");
|
||||
expect(panel).toContain("visibleChatId.current = nextVisibleChat(chatId, { leaving: false });");
|
||||
expect(panel).toContain("visibleChatId.current = nextVisibleChat(chatId, { leaving: true });");
|
||||
});
|
||||
|
||||
/**
|
||||
* Structural: there is no DOM here to open a panel in. The poll runs for as long as this tab
|
||||
* is watching, so the chat on screen has to be read from a ref at request time — `open` in the
|
||||
* callback's closure is whatever it was when polling started.
|
||||
*/
|
||||
it("names the chat on screen to the poll instead of correcting the count it gets back", () => {
|
||||
expect(layout).toContain("const onScreen = visibleChat.current;");
|
||||
expect(layout).toContain("setUnreadWork(Math.max(0, data.unreadWork ?? 0));");
|
||||
expect(layout).not.toContain("panelOpen.current");
|
||||
});
|
||||
|
||||
it("re-seeds both counts when the environment changes under the layout", () => {
|
||||
expect(layout).toContain("seededEnvironment.current = environment.id;");
|
||||
expect(layout).toContain("setUnreadWakes(initialUnreadWakes);");
|
||||
expect(layout).toContain("setUnreadWork(initialUnreadWork);");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* What the launcher's dot counts.
|
||||
*
|
||||
* The counts are derived from the chat list rather than nudged up and down as chats are
|
||||
* opened: a decrement fires once per read effect and once per cleanup, and again on every
|
||||
* revisit, none of which the server ever hears about. Reading a chat settles it in the list,
|
||||
* and the list is what the dot counts — so the same chat read twice counts once.
|
||||
*/
|
||||
|
||||
type UnreadChat = { id: string; hasUnreadWake?: boolean; hasUnreadWork?: boolean };
|
||||
|
||||
/**
|
||||
* The chat the panel has on screen. `leaving` is the read effect's cleanup: it runs after the
|
||||
* panel has already let go, so restoring the id there would keep hiding that chat's wakes.
|
||||
*/
|
||||
export function nextVisibleChat(chatId: string, options: { leaving: boolean }): string | null {
|
||||
return options.leaving ? null : chatId;
|
||||
}
|
||||
|
||||
/** Opening a chat settles everything unseen in it, not just the wake. */
|
||||
export function markChatListRead<T extends UnreadChat>(chats: T[], chatId: string): T[] {
|
||||
return chats.map((chat) =>
|
||||
chat.id === chatId ? { ...chat, hasUnreadWake: false, hasUnreadWork: false } : chat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list as the panel renders it. The chat on screen settles alongside the ones just read:
|
||||
* the server's lastReadAt still trails the turn that just landed in it, so a refresh would
|
||||
* otherwise mark the chat its owner is reading right now as unread.
|
||||
*/
|
||||
export function settleReadChats<T extends UnreadChat>(
|
||||
chats: T[],
|
||||
read: Set<string>,
|
||||
visibleChatId: string | null
|
||||
): T[] {
|
||||
return chats.map((chat) =>
|
||||
read.has(chat.id) || chat.id === visibleChatId
|
||||
? { ...chat, hasUnreadWake: false, hasUnreadWork: false }
|
||||
: chat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many chats still hold work their owner hasn't seen. The chat on screen is being read
|
||||
* right now, so a turn landing in it is not work anyone is waiting on — every count of this,
|
||||
* here and on the server, leaves it out, so none of them has to be corrected afterwards.
|
||||
*/
|
||||
export function unreadWorkCount(chats: UnreadChat[], visibleChatId?: string | null): number {
|
||||
return chats.filter((chat) => chat.hasUnreadWork && chat.id !== visibleChatId).length;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chatIsUnread } from "./DashboardAgentHistory";
|
||||
|
||||
/**
|
||||
* A chat is unread when its transcript moved on after its owner last looked — whether that
|
||||
* was a watch waking it or an answer that landed while the panel was closed. Both raise the
|
||||
* dot and the highlight; only a wake also raises a toast.
|
||||
*/
|
||||
describe("chatIsUnread", () => {
|
||||
const chat = (over: Record<string, unknown> = {}) =>
|
||||
({ id: "chat_1", title: "t", lastMessageAt: null, ...over }) as never;
|
||||
|
||||
it("counts work that finished behind a closed panel", () => {
|
||||
expect(chatIsUnread(chat({ hasUnreadWork: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("still counts a watch wake", () => {
|
||||
expect(chatIsUnread(chat({ hasUnreadWake: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a chat its owner has seen", () => {
|
||||
expect(chatIsUnread(chat())).toBe(false);
|
||||
expect(chatIsUnread(chat({ hasUnreadWake: false, hasUnreadWork: false }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,28 @@
|
||||
import type { ActionsBlockAction } from "@internal/dashboard-agent-contracts";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { answerContinuesAfter, renderableActions } from "./view-actions";
|
||||
import {
|
||||
answerContinuesAfter,
|
||||
cardAlreadyOffersWatch,
|
||||
renderableActions,
|
||||
turnAlreadyOffersWatch,
|
||||
turnProposesWatch,
|
||||
withoutWatchActions,
|
||||
} from "./view-actions";
|
||||
|
||||
const watchAction: ActionsBlockAction = {
|
||||
label: "Set up a watch",
|
||||
intent: {
|
||||
kind: "watch",
|
||||
spec: {
|
||||
kind: "error_recurrence",
|
||||
fingerprint: "a1b2c3",
|
||||
checkEveryMinutes: 15,
|
||||
maxHours: 6,
|
||||
note: "the TypeError in send-order-receipt",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const askAction: ActionsBlockAction = {
|
||||
label: "Investigate it",
|
||||
@@ -25,6 +46,10 @@ describe("renderableActions", () => {
|
||||
expect(renderableActions([navigate])).toEqual([navigate]);
|
||||
});
|
||||
|
||||
it("keeps a watch action, spec intact — that spec is what pre-fills the card", () => {
|
||||
expect(renderableActions([watchAction, askAction])).toEqual([watchAction, askAction]);
|
||||
});
|
||||
|
||||
it("can filter every action out, leaving nothing to render", () => {
|
||||
expect(
|
||||
renderableActions([{ label: "Nowhere", intent: { kind: "navigate", target: "nope" } }])
|
||||
@@ -47,6 +72,96 @@ describe("keep digging, only while there is digging left", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("one watch button per answer", () => {
|
||||
const watchAction = { label: "Watch for a repeat", intent: { kind: "watch" as const, spec: {} } };
|
||||
const card = (actions: unknown[]) =>
|
||||
({ type: "investigation", investigation: {}, capabilities: { actions } }) as never;
|
||||
|
||||
it("sees the card's own watch offer", () => {
|
||||
expect(cardAlreadyOffersWatch([card([watchAction])])).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves an answer whose card offers no watch alone", () => {
|
||||
expect(
|
||||
cardAlreadyOffersWatch([
|
||||
card([{ label: "Keep digging", intent: { kind: "ask", prompt: "" } }]),
|
||||
])
|
||||
).toBe(false);
|
||||
expect(cardAlreadyOffersWatch([])).toBe(false);
|
||||
});
|
||||
|
||||
// The bug this closes: one `render_view` call carries the investigation card and a second
|
||||
// carries the actions block, so each call asked only about its own blocks and said no.
|
||||
it("sees a watch offered by another of the same turn's render_view calls", () => {
|
||||
const investigationCall = [card([watchAction])];
|
||||
const actionsCall = [{ type: "actions", actions: [watchAction] }] as never[];
|
||||
|
||||
expect(cardAlreadyOffersWatch(actionsCall)).toBe(false);
|
||||
// Either order: the card can be rendered before or after the block that repeats it.
|
||||
expect(turnAlreadyOffersWatch([investigationCall, actionsCall])).toBe(true);
|
||||
expect(turnAlreadyOffersWatch([actionsCall, investigationCall])).toBe(true);
|
||||
});
|
||||
|
||||
// The report card grows its own "Watch…" button from the view model, so the block
|
||||
// carries no watch action to match on.
|
||||
const reportCard = (title: string, severity: string) =>
|
||||
({ type: "report", vm: { title, summary: { severity, statements: [] } } }) as never;
|
||||
|
||||
it("sees the health report card's recovery watch", () => {
|
||||
expect(cardAlreadyOffersWatch([reportCard("health", "crit")])).toBe(true);
|
||||
expect(cardAlreadyOffersWatch([reportCard("health", "warn")])).toBe(true);
|
||||
const actionsCall = [{ type: "actions", actions: [watchAction] }] as never[];
|
||||
expect(turnAlreadyOffersWatch([[reportCard("health", "crit")], actionsCall])).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a report card with no watch button alone", () => {
|
||||
// Green: nothing to recover from. And only the health report has a recovery watch.
|
||||
expect(cardAlreadyOffersWatch([reportCard("health", "ok")])).toBe(false);
|
||||
expect(cardAlreadyOffersWatch([reportCard("cost", "crit")])).toBe(false);
|
||||
});
|
||||
|
||||
it("says no when no call in the turn has a card offering one", () => {
|
||||
const plain = [card([{ label: "Keep digging", intent: { kind: "ask", prompt: "" } }])];
|
||||
expect(turnAlreadyOffersWatch([plain, []])).toBe(false);
|
||||
expect(turnAlreadyOffersWatch([])).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the model's duplicate offer, keeping everything else", () => {
|
||||
expect(
|
||||
withoutWatchActions([
|
||||
{ label: "Set up a watch", intent: { kind: "watch", spec: {} } },
|
||||
{ label: "View similar", intent: { kind: "navigate", target: "trigger://x" } },
|
||||
] as never)
|
||||
).toEqual([{ label: "View similar", intent: { kind: "navigate", target: "trigger://x" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a turn that proposed a watch through the tool", () => {
|
||||
const text = { type: "text", text: "here is what I found" };
|
||||
const scheduled = (output: unknown, state = "output-available") => ({
|
||||
type: "tool-schedule_watch",
|
||||
state,
|
||||
output,
|
||||
});
|
||||
const intent = { intent: watchAction.intent };
|
||||
|
||||
it("sees the proposal that opened the card", () => {
|
||||
expect(turnProposesWatch([text, scheduled(intent)])).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the button alone when the spec was rejected — no card opened", () => {
|
||||
expect(turnProposesWatch([text, scheduled({ error: "Couldn't build that watch: bad" })])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for the output: a call still running proposes nothing", () => {
|
||||
expect(turnProposesWatch([scheduled(intent, "input-available")])).toBe(false);
|
||||
expect(turnProposesWatch([{ type: "tool-schedule_watch", output: intent }])).toBe(false);
|
||||
expect(turnProposesWatch([text])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActionsBlock", () => {
|
||||
const source = readFileSync(new URL("./ActionsBlock.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -56,7 +171,7 @@ describe("ActionsBlock", () => {
|
||||
});
|
||||
|
||||
it("filters through the shared filter rather than rendering every action", () => {
|
||||
expect(source).toContain("renderableActions(block.actions)");
|
||||
expect(source).toContain("renderableActions(actions)");
|
||||
});
|
||||
|
||||
it("is a pure component: no app hooks, no server module, no Remix", () => {
|
||||
@@ -65,3 +180,30 @@ describe("ActionsBlock", () => {
|
||||
expect(source).not.toMatch(/\.server"/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* There is no rendering harness here, so this pins the wiring rather than the pixels: the
|
||||
* turn-wide answer is computed where every part is in scope and reaches every card, and
|
||||
* `ViewBlocks` can only add to it. What it does not prove is that the button disappears.
|
||||
*/
|
||||
describe("the one-watch-button flag is decided per turn, not per render_view call", () => {
|
||||
const turn = readFileSync(new URL("./DashboardAgentMessages.tsx", import.meta.url), "utf8");
|
||||
const catalog = readFileSync(new URL("./view-catalog.tsx", import.meta.url), "utf8");
|
||||
|
||||
it("computes it over every part's blocks, above the per-part loop", () => {
|
||||
expect(turn).toContain("turnAlreadyOffersWatch(");
|
||||
// Above the loop: computed from the whole `parts` map, not from one part.
|
||||
expect(turn.indexOf("const watchOfferedInTurn")).toBeLessThan(
|
||||
turn.indexOf("for (let i = 0; i < parts.length; i++)")
|
||||
);
|
||||
expect(turn).toContain("watchOfferedInTurn={watchOfferedInTurn}");
|
||||
});
|
||||
|
||||
it("counts the turn's own schedule_watch proposal as an offer", () => {
|
||||
expect(turn).toMatch(/turnProposesWatch\(parts as never\) \|\|/);
|
||||
});
|
||||
|
||||
it("lets a card add its own offer but never drop the turn's", () => {
|
||||
expect(catalog).toMatch(/watchOfferedInTurn \|\|\s*cardAlreadyOffersWatch\(/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// A navigate target is a plain string at the contract boundary, so only targets
|
||||
// that parse become buttons: a hallucinated URI costs a button, never a dead click.
|
||||
import {
|
||||
agentIntentSchema,
|
||||
isTriggerUri,
|
||||
type ActionsBlockAction,
|
||||
type ChartAction,
|
||||
type ReportViewModelPayload,
|
||||
type ViewBlock,
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
|
||||
type CardAction = ChartAction | ActionsBlockAction;
|
||||
@@ -15,6 +18,61 @@ export function renderableActions<T extends CardAction>(actions: T[]): T[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An investigation card carries its own "watch for a repeat" button, and the model is
|
||||
* asked to end an unresolved answer with a watch offer — so an answer that does both
|
||||
* shows the same button twice. The card wins: it is the one with the pre-filled spec.
|
||||
*/
|
||||
export function cardAlreadyOffersWatch(blocks: ViewBlock[]): boolean {
|
||||
return blocks.some((block) => {
|
||||
if (block.type === "investigation") {
|
||||
return (block.capabilities?.actions ?? []).some((action) => action.intent.kind === "watch");
|
||||
}
|
||||
return block.type === "report" && reportOffersRecoveryWatch(block.vm);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The report card's watch button isn't in the block — `ReportView` grows it from the
|
||||
* view model, for a health report with something to recover from. Same condition here,
|
||||
* so the button the user will see is the one the guard counts. A predicate, so a caller
|
||||
* building the recovery spec keeps the narrowed severity.
|
||||
*/
|
||||
export function reportOffersRecoveryWatch(
|
||||
vm: ReportViewModelPayload
|
||||
): vm is ReportViewModelPayload & { summary: { severity: "warn" | "crit" } } {
|
||||
return (
|
||||
vm.title === "health" && (vm.summary.severity === "warn" || vm.summary.severity === "crit")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question across every card a turn renders. One `render_view` call can carry the
|
||||
* investigation card and another the actions block, so a per-call answer misses the pair.
|
||||
*/
|
||||
export function turnAlreadyOffersWatch(blockGroups: ViewBlock[][]): boolean {
|
||||
return blockGroups.some(cardAlreadyOffersWatch);
|
||||
}
|
||||
|
||||
/**
|
||||
* `schedule_watch` opens the pre-filled card itself, so a button repeating it is dead.
|
||||
* A rejected spec returns an error instead of an intent: no card opens, so the button stays.
|
||||
*/
|
||||
export function turnProposesWatch(
|
||||
parts: ReadonlyArray<{ type?: string; state?: string; output?: unknown }>
|
||||
): boolean {
|
||||
return parts.some(
|
||||
(part) =>
|
||||
part.type === "tool-schedule_watch" &&
|
||||
part.state === "output-available" &&
|
||||
agentIntentSchema.safeParse((part.output as { intent?: unknown } | undefined)?.intent).success
|
||||
);
|
||||
}
|
||||
|
||||
export function withoutWatchActions<T extends CardAction>(actions: T[]): T[] {
|
||||
return actions.filter((action) => action.intent.kind !== "watch");
|
||||
}
|
||||
|
||||
/**
|
||||
* "Keep digging" asks the agent to carry on — which is pointless once it already has.
|
||||
* A turn that renders an inconclusive card and then keeps answering leaves the button
|
||||
|
||||
@@ -55,6 +55,16 @@ const FIXTURES: Record<EnvelopedViewBlock["type"], EnvelopedViewBlock> = {
|
||||
footer: [],
|
||||
},
|
||||
},
|
||||
watch_result: {
|
||||
...envelope("watch:watch_1"),
|
||||
type: "watch_result",
|
||||
outcome: "watching",
|
||||
headline: "Watching send-order-receipt for failures.",
|
||||
lifetime: "24h",
|
||||
detail: null,
|
||||
followUp: [],
|
||||
watchId: "watch_1",
|
||||
},
|
||||
investigation: {
|
||||
...envelope("investigation-1"),
|
||||
type: "investigation",
|
||||
|
||||
@@ -5,6 +5,8 @@ import { InvestigationCard } from "./InvestigationCard";
|
||||
import { ReportView, type ResolvedUri } from "./ReportView";
|
||||
import { RunDiagnosisCard } from "./RunDiagnosisCard";
|
||||
import { blockKey, latestRevisionEntries } from "./view-blocks";
|
||||
import { cardAlreadyOffersWatch } from "./view-actions";
|
||||
import { WatchResultBlock } from "./WatchResultBlock";
|
||||
|
||||
// Unknown block types are skipped, so an older or newer agent cannot render
|
||||
// arbitrary content. A new block needs a `case` here and a `viewBlockSchema` member.
|
||||
@@ -14,6 +16,7 @@ export function ViewBlocks({
|
||||
resolveUri,
|
||||
pagePaths,
|
||||
answered = false,
|
||||
watchOfferedInTurn = false,
|
||||
}: {
|
||||
blocks: ViewBlock[];
|
||||
onIntent?: (intent: AgentIntent) => void;
|
||||
@@ -21,11 +24,16 @@ export function ViewBlocks({
|
||||
pagePaths?: Record<string, string>;
|
||||
/** The turn kept answering after this card, so "keep digging" has nothing to ask for. */
|
||||
answered?: boolean;
|
||||
/** A card in another of this turn's parts already offers the watch; see `view-actions`. */
|
||||
watchOfferedInTurn?: boolean;
|
||||
}) {
|
||||
if (!Array.isArray(blocks)) return null;
|
||||
const entries = latestRevisionEntries(blocks);
|
||||
const watchOfferedOnCard =
|
||||
watchOfferedInTurn || cardAlreadyOffersWatch(entries.map((entry) => entry.block));
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{latestRevisionEntries(blocks).map(({ block, index }) => {
|
||||
{entries.map(({ block, index }) => {
|
||||
// The original array's index, so collapsing a revision above an
|
||||
// envelope-less block can't shift its key.
|
||||
const key = blockKey(block, index);
|
||||
@@ -35,7 +43,14 @@ export function ViewBlocks({
|
||||
case "chart":
|
||||
return <AgentChart key={key} block={block} onIntent={onIntent} />;
|
||||
case "actions":
|
||||
return <ActionsBlock key={key} block={block} onIntent={onIntent} />;
|
||||
return (
|
||||
<ActionsBlock
|
||||
key={key}
|
||||
block={block}
|
||||
onIntent={onIntent}
|
||||
dropWatch={watchOfferedOnCard}
|
||||
/>
|
||||
);
|
||||
// Revisions share the investigationId, so latest-wins keeps one card.
|
||||
case "investigation":
|
||||
return (
|
||||
@@ -47,6 +62,9 @@ export function ViewBlocks({
|
||||
answered={answered}
|
||||
/>
|
||||
);
|
||||
// Host-emitted only, so the model cannot fabricate a confirmation.
|
||||
case "watch_result":
|
||||
return <WatchResultBlock key={key} block={block} />;
|
||||
case "report":
|
||||
return (
|
||||
<ReportView
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { wakePresentation, wakeRefFromMessageId, wakeResolution } from "./WakeBanner";
|
||||
import { watchWakeToastTitle } from "./WatchWakeToast";
|
||||
|
||||
const runWatch = {
|
||||
id: "watch_1",
|
||||
kind: "run_finished",
|
||||
identity: "run_finished:run_abc123",
|
||||
note: "tell me when the nightly invoice run finishes",
|
||||
};
|
||||
|
||||
describe("wakeRefFromMessageId", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
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 a1b2c3d4e5f6 happened again",
|
||||
category: "attention",
|
||||
});
|
||||
expect(wakePresentation("expired", { ...error, resolution: "window_completed" })).toMatchObject(
|
||||
{ headline: "Error a1b2c3d4e5f6 stayed quiet", category: "positive" }
|
||||
);
|
||||
});
|
||||
|
||||
it("says a queue came back below its threshold, and when it never did", () => {
|
||||
const below = {
|
||||
id: "watch_below",
|
||||
kind: "queue_depth_below",
|
||||
identity: "queue_depth_below:email-sends:100",
|
||||
note: "",
|
||||
};
|
||||
expect(
|
||||
wakePresentation("fired", {
|
||||
...below,
|
||||
resolution: "condition_met",
|
||||
observedOutcome: { kind: "queue_depth_below", verified: true, depth: 42, threshold: 100 },
|
||||
})
|
||||
).toMatchObject({ headline: "email-sends queue is back below 100", category: "positive" });
|
||||
|
||||
expect(
|
||||
wakePresentation("expired", {
|
||||
...below,
|
||||
resolution: "window_completed",
|
||||
observedOutcome: { kind: "queue_depth_below", verified: true, depth: 780, threshold: 100 },
|
||||
})
|
||||
).toMatchObject({ headline: "email-sends queue is still above 100", category: "attention" });
|
||||
});
|
||||
|
||||
it("says a queue is stuck at the depth it stalled on, and that it kept moving", () => {
|
||||
const stalled = {
|
||||
id: "watch_stalled",
|
||||
kind: "queue_stalled",
|
||||
identity: "queue_stalled:email-sends",
|
||||
note: "",
|
||||
};
|
||||
expect(
|
||||
wakePresentation("fired", {
|
||||
...stalled,
|
||||
resolution: "condition_met",
|
||||
observedOutcome: {
|
||||
kind: "queue_stalled",
|
||||
verified: true,
|
||||
depth: 42,
|
||||
notDecreasingStreak: 3,
|
||||
ticks: 3,
|
||||
},
|
||||
})
|
||||
).toMatchObject({ headline: "email-sends queue is stuck at 42", category: "attention" });
|
||||
|
||||
expect(
|
||||
wakePresentation("expired", {
|
||||
...stalled,
|
||||
resolution: "window_completed",
|
||||
observedOutcome: {
|
||||
kind: "queue_stalled",
|
||||
verified: true,
|
||||
depth: 3,
|
||||
notDecreasingStreak: 1,
|
||||
ticks: 3,
|
||||
},
|
||||
})
|
||||
).toMatchObject({ headline: "email-sends queue kept moving", category: "positive" });
|
||||
});
|
||||
|
||||
it("states the wait and the limit it passed, in minutes", () => {
|
||||
const age = {
|
||||
id: "watch_age",
|
||||
kind: "queue_oldest_age",
|
||||
identity: "queue_oldest_age:email-sends:5",
|
||||
note: "",
|
||||
};
|
||||
expect(
|
||||
wakePresentation("fired", {
|
||||
...age,
|
||||
resolution: "condition_met",
|
||||
observedOutcome: {
|
||||
kind: "queue_oldest_age",
|
||||
verified: true,
|
||||
ageMs: 12 * 60_000,
|
||||
thresholdMinutes: 5,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
headline: "runs in email-sends are waiting 12m (over your 5m limit)",
|
||||
category: "attention",
|
||||
});
|
||||
|
||||
expect(
|
||||
wakePresentation("expired", {
|
||||
...age,
|
||||
resolution: "window_completed",
|
||||
observedOutcome: {
|
||||
kind: "queue_oldest_age",
|
||||
verified: true,
|
||||
ageMs: 30_000,
|
||||
thresholdMinutes: 5,
|
||||
},
|
||||
})
|
||||
).toMatchObject({ headline: "email-sends queue stayed under 5m", category: "positive" });
|
||||
});
|
||||
|
||||
it("names the queue, not the threshold, when a queue-pack watch's queue is gone", () => {
|
||||
for (const [kind, identity] of [
|
||||
["queue_depth_below", "queue_depth_below:email-sends:100"],
|
||||
["queue_stalled", "queue_stalled:email-sends"],
|
||||
["queue_oldest_age", "queue_oldest_age:email-sends:5"],
|
||||
] as const) {
|
||||
expect(
|
||||
wakePresentation("expired", {
|
||||
id: `watch_${kind}`,
|
||||
kind,
|
||||
identity,
|
||||
note: "",
|
||||
resolution: "condition_impossible",
|
||||
})
|
||||
).toMatchObject({ headline: "email-sends queue no longer exists", category: "neutral" });
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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."
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createWakePendingCount,
|
||||
planWakeToasts,
|
||||
startWakePolling,
|
||||
UNREAD_POLL_INTERVAL_MS,
|
||||
wakesToToast,
|
||||
} from "./wake-poll";
|
||||
|
||||
function harness() {
|
||||
let hidden = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const loads: number[] = [];
|
||||
|
||||
const stop = startWakePolling({
|
||||
load: async () => {
|
||||
loads.push(Date.now());
|
||||
},
|
||||
isHidden: () => hidden,
|
||||
onVisibilityChange: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
// No jitter, so every delay is exactly one interval.
|
||||
random: () => 0,
|
||||
setTimer: (callback, ms) => setTimeout(callback, ms) as unknown as number,
|
||||
clearTimer: (handle) => clearTimeout(handle as unknown as NodeJS.Timeout),
|
||||
});
|
||||
|
||||
return {
|
||||
loads,
|
||||
stop,
|
||||
setHidden(next: boolean) {
|
||||
hidden = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
describe("startWakePolling", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("polls once immediately and then once per interval", async () => {
|
||||
const poll = harness();
|
||||
|
||||
expect(poll.loads).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 3);
|
||||
expect(poll.loads).toHaveLength(4);
|
||||
|
||||
poll.stop();
|
||||
});
|
||||
|
||||
it("asks nothing while hidden and catches up once when visible again", async () => {
|
||||
const poll = harness();
|
||||
poll.setHidden(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 3);
|
||||
expect(poll.loads).toHaveLength(1);
|
||||
|
||||
poll.setHidden(false);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(poll.loads).toHaveLength(2);
|
||||
|
||||
poll.stop();
|
||||
});
|
||||
|
||||
it("keeps exactly one chain across ten rapid hide/show cycles", async () => {
|
||||
const poll = harness();
|
||||
|
||||
for (let cycle = 0; cycle < 10; cycle++) {
|
||||
poll.setHidden(true);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
poll.setHidden(false);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
}
|
||||
|
||||
const afterCycles = poll.loads.length;
|
||||
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 10);
|
||||
|
||||
// One poll per interval, not ten: the resumes replaced the chain instead of
|
||||
// forking it.
|
||||
expect(poll.loads.length - afterCycles).toBe(10);
|
||||
|
||||
poll.stop();
|
||||
});
|
||||
|
||||
it("stops every timer and listener on unmount", async () => {
|
||||
const poll = harness();
|
||||
poll.setHidden(true);
|
||||
poll.setHidden(false);
|
||||
|
||||
poll.stop();
|
||||
const settled = poll.loads.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(UNREAD_POLL_INTERVAL_MS * 10);
|
||||
expect(poll.loads).toHaveLength(settled);
|
||||
expect(poll.listenerCount()).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wakesToToast", () => {
|
||||
const wake = (watchId: string, unread: boolean) => ({ watchId, unread });
|
||||
|
||||
it("skips a wake another machine already read, and keeps the unread one", () => {
|
||||
const wakes = [wake("watch_read", false), wake("watch_new", true)];
|
||||
|
||||
expect(wakesToToast(wakes, new Set())).toEqual([wake("watch_new", true)]);
|
||||
});
|
||||
|
||||
it("still skips what this browser toasted, read or not", () => {
|
||||
const wakes = [wake("watch_seen", true), wake("watch_new", true)];
|
||||
|
||||
expect(wakesToToast(wakes, new Set(["watch_seen"]))).toEqual([wake("watch_new", true)]);
|
||||
});
|
||||
|
||||
// The read POST is what clears it, and that only runs once the chat is looked at.
|
||||
it("toasts a wake that landed in an open chat, because it is still unread", () => {
|
||||
expect(wakesToToast([wake("watch_in_view", true)], new Set())).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats a wake with no unread flag as already seen rather than guessing", () => {
|
||||
expect(wakesToToast([{ watchId: "watch_old" }], new Set())).toEqual([]);
|
||||
expect(wakesToToast(undefined, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planWakeToasts", () => {
|
||||
const MAX = 3;
|
||||
const batch = (n: number) => Array.from({ length: n }, (_, i) => i);
|
||||
|
||||
it("toasts a small batch individually but still counts it toward the running total", () => {
|
||||
const { plan, pending } = planWakeToasts(batch(2), 0, MAX);
|
||||
|
||||
expect(plan).toEqual({ mode: "individual", wakes: [0, 1] });
|
||||
expect(pending).toBe(2);
|
||||
});
|
||||
|
||||
it("summarizes when a single batch is over the max", () => {
|
||||
const { plan, pending } = planWakeToasts(batch(4), 0, MAX);
|
||||
|
||||
expect(plan).toEqual({ mode: "summary", count: 4 });
|
||||
expect(pending).toBe(4);
|
||||
});
|
||||
|
||||
it("accumulates across consecutive polls instead of showing only the latest", () => {
|
||||
// First batch of 2 is below the max: individual toasts, nothing pending yet.
|
||||
const first = planWakeToasts(batch(2), 0, MAX);
|
||||
expect(first.plan.mode).toBe("individual");
|
||||
|
||||
// A second batch of 3 pushes the running total to 5, so the grouped toast claims
|
||||
// the cumulative count, not just this batch's 3.
|
||||
const second = planWakeToasts(batch(3), first.pending, MAX);
|
||||
expect(second.plan).toEqual({ mode: "summary", count: 5 });
|
||||
expect(second.pending).toBe(5);
|
||||
});
|
||||
|
||||
it("grows the visible summary as later batches arrive", () => {
|
||||
const first = planWakeToasts(batch(4), 0, MAX);
|
||||
const second = planWakeToasts(batch(3), first.pending, MAX);
|
||||
|
||||
expect(second.plan).toEqual({ mode: "summary", count: 7 });
|
||||
expect(second.pending).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWakePendingCount", () => {
|
||||
const MAX = 3;
|
||||
const batch = (n: number) => Array.from({ length: n }, (_, i) => i);
|
||||
|
||||
it("carries unacknowledged wakes into the summary", () => {
|
||||
const count = createWakePendingCount();
|
||||
|
||||
expect(count.plan(batch(2), MAX)).toEqual({ mode: "individual", wakes: [0, 1] });
|
||||
expect(count.plan(batch(2), MAX)).toEqual({ mode: "summary", count: 4 });
|
||||
});
|
||||
|
||||
it("does not count wakes the user already opened", () => {
|
||||
const count = createWakePendingCount();
|
||||
|
||||
// Two individual toasts, both opened — from the toast, ⌘J, anywhere.
|
||||
expect(count.plan(batch(2), MAX).mode).toBe("individual");
|
||||
count.acknowledge();
|
||||
|
||||
// Only the two new wakes are waiting, so they toast individually rather than
|
||||
// claiming "4 watch updates".
|
||||
expect(count.plan(batch(2), MAX)).toEqual({ mode: "individual", wakes: [0, 1] });
|
||||
});
|
||||
|
||||
it("starts the next summary from the wakes that arrived after the open", () => {
|
||||
const count = createWakePendingCount();
|
||||
|
||||
expect(count.plan(batch(4), MAX)).toEqual({ mode: "summary", count: 4 });
|
||||
count.acknowledge();
|
||||
|
||||
expect(count.plan(batch(2), MAX).mode).toBe("individual");
|
||||
expect(count.plan(batch(2), MAX)).toEqual({ mode: "summary", count: 4 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The wake feed's poll: one self-scheduling chain per mount. A hidden tab asks nothing, a
|
||||
* resume catches up once, and neither can fork the chain into a second one.
|
||||
*/
|
||||
|
||||
export const UNREAD_POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
// Added to each delay so open tabs never settle into polling on the same second.
|
||||
export const UNREAD_POLL_JITTER_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Which of the feed's wakes this tab should toast. The feed is recent deliveries, not
|
||||
* unread ones, and the local memory of what was toasted is per browser — so `unread` is the
|
||||
* only signal shared across machines that a wake has already been seen. A wake landing in
|
||||
* an open chat stays unread until that chat's next read, so it still toasts.
|
||||
*/
|
||||
export function wakesToToast<T extends { watchId: string; unread?: boolean }>(
|
||||
wakes: T[] | undefined,
|
||||
toasted: ReadonlySet<string>
|
||||
): T[] {
|
||||
return (wakes ?? []).filter((wake) => wake.unread === true && !toasted.has(wake.watchId));
|
||||
}
|
||||
|
||||
export type WakeToastPlan<T> =
|
||||
| { mode: "summary"; count: number }
|
||||
| { mode: "individual"; wakes: T[] };
|
||||
|
||||
/**
|
||||
* Whether this poll's fresh wakes join a grouped summary or each get their own toast.
|
||||
* `pending` is the running count of unacknowledged wakes carried from earlier polls; a
|
||||
* batch that pushes the total past `max` shows the summary with that cumulative count, so
|
||||
* a later batch adds to it rather than replacing it with only its own, smaller number.
|
||||
* The returned `pending` is what the caller carries into the next poll; it resets to zero
|
||||
* once the user acknowledges (opens the panel).
|
||||
*/
|
||||
export function planWakeToasts<T>(
|
||||
fresh: T[],
|
||||
pending: number,
|
||||
max: number
|
||||
): { plan: WakeToastPlan<T>; pending: number } {
|
||||
const total = pending + fresh.length;
|
||||
if (total > max) {
|
||||
return { plan: { mode: "summary", count: total }, pending: total };
|
||||
}
|
||||
return { plan: { mode: "individual", wakes: fresh }, pending: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* The running pending count, owned by one holder so the poll and the panel cannot drift.
|
||||
* Every wake counts until the user opens the panel — by whichever route, including a single
|
||||
* wake toast — and opening it clears the count so a later grouped toast claims only wakes
|
||||
* still waiting.
|
||||
*/
|
||||
export function createWakePendingCount() {
|
||||
let pending = 0;
|
||||
|
||||
return {
|
||||
plan<T>(fresh: T[], max: number): WakeToastPlan<T> {
|
||||
const result = planWakeToasts(fresh, pending, max);
|
||||
pending = result.pending;
|
||||
return result.plan;
|
||||
},
|
||||
acknowledge() {
|
||||
pending = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type WakePollOptions = {
|
||||
load: () => Promise<void>;
|
||||
isHidden: () => boolean;
|
||||
/** Subscribe to visibility changes; returns its own unsubscribe. */
|
||||
onVisibilityChange: (listener: () => void) => () => void;
|
||||
/** Seams so a test can drive the chain without real timers. */
|
||||
random?: () => number;
|
||||
setTimer?: (callback: () => void, delayMs: number) => number;
|
||||
clearTimer?: (handle: number) => void;
|
||||
};
|
||||
|
||||
/** Start polling. The returned function stops the chain for good. */
|
||||
export function startWakePolling(options: WakePollOptions): () => void {
|
||||
const random = options.random ?? Math.random;
|
||||
const setTimer = options.setTimer ?? ((callback, ms) => window.setTimeout(callback, ms));
|
||||
const clearTimer = options.clearTimer ?? ((handle) => window.clearTimeout(handle));
|
||||
|
||||
let stopped = false;
|
||||
let timer: number | undefined;
|
||||
let loading = false;
|
||||
// Each tick carries the chain it belongs to, so an orphaned callback returns
|
||||
// instead of scheduling itself again.
|
||||
let chain = 0;
|
||||
|
||||
const tick = (generation: number) => {
|
||||
if (stopped || generation !== chain) return;
|
||||
|
||||
// Scheduled before the load, so a slow response can't stall the chain.
|
||||
timer = setTimer(
|
||||
() => tick(generation),
|
||||
UNREAD_POLL_INTERVAL_MS + random() * UNREAD_POLL_JITTER_MS
|
||||
);
|
||||
|
||||
if (loading || options.isHidden()) return;
|
||||
loading = true;
|
||||
const done = () => {
|
||||
loading = false;
|
||||
};
|
||||
options.load().then(done, done);
|
||||
};
|
||||
|
||||
const unsubscribe = options.onVisibilityChange(() => {
|
||||
if (stopped || options.isHidden()) return;
|
||||
// One catch-up fetch on a new chain, replacing the pending timer rather than
|
||||
// adding a second chain.
|
||||
if (timer !== undefined) clearTimer(timer);
|
||||
chain += 1;
|
||||
tick(chain);
|
||||
});
|
||||
|
||||
tick(chain);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer !== undefined) clearTimer(timer);
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/** The key the module under test writes; kept here so a rename fails loudly in one place. */
|
||||
const STORAGE_KEY = "tdev:dashboard-agent:watching";
|
||||
|
||||
type StorageListener = (event: { key: string | null }) => void;
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const storageListeners = new Set<StorageListener>();
|
||||
|
||||
// A minimal `window`: these tests run without a DOM.
|
||||
const windowStub = {
|
||||
localStorage: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
},
|
||||
addEventListener: (_type: string, listener: StorageListener) =>
|
||||
void storageListeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: StorageListener) =>
|
||||
void storageListeners.delete(listener),
|
||||
};
|
||||
|
||||
const {
|
||||
forgetWatchActivity,
|
||||
hasWatchActivity,
|
||||
rememberWatchActivity,
|
||||
shouldPollWakeFeed,
|
||||
subscribeWatchActivity,
|
||||
} = await import("./watch-activity");
|
||||
|
||||
/** What another tab writing the key looks like here. */
|
||||
function otherTabWrote(organizationId: string) {
|
||||
store.set(STORAGE_KEY, JSON.stringify([organizationId]));
|
||||
for (const listener of storageListeners) listener({ key: STORAGE_KEY });
|
||||
}
|
||||
|
||||
describe("watch activity", () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
storageListeners.clear();
|
||||
vi.stubGlobal("window", windowStub);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("knows nothing until a watch shows up", () => {
|
||||
expect(hasWatchActivity("org_1")).toBe(false);
|
||||
|
||||
rememberWatchActivity("org_1");
|
||||
expect(hasWatchActivity("org_1")).toBe(true);
|
||||
expect(hasWatchActivity("org_2")).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a reload", () => {
|
||||
rememberWatchActivity("org_1");
|
||||
storageListeners.clear();
|
||||
|
||||
expect(hasWatchActivity("org_1")).toBe(true);
|
||||
});
|
||||
|
||||
it("tells a tab that was already open", () => {
|
||||
const woken: number[] = [];
|
||||
const unsubscribe = subscribeWatchActivity(() => woken.push(1));
|
||||
|
||||
rememberWatchActivity("org_1");
|
||||
expect(woken).toHaveLength(1);
|
||||
|
||||
unsubscribe();
|
||||
rememberWatchActivity("org_2");
|
||||
expect(woken).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tells a tab about a watch another tab created", () => {
|
||||
const woken: number[] = [];
|
||||
const unsubscribe = subscribeWatchActivity(() => woken.push(1));
|
||||
|
||||
otherTabWrote("org_1");
|
||||
|
||||
expect(woken).toHaveLength(1);
|
||||
expect(hasWatchActivity("org_1")).toBe(true);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("forgets one organization without forgetting the others", () => {
|
||||
rememberWatchActivity("org_1");
|
||||
rememberWatchActivity("org_2");
|
||||
|
||||
forgetWatchActivity("org_1");
|
||||
|
||||
expect(hasWatchActivity("org_1")).toBe(false);
|
||||
expect(hasWatchActivity("org_2")).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers at most ten organizations", () => {
|
||||
for (let index = 0; index < 12; index++) rememberWatchActivity(`org_${index}`);
|
||||
|
||||
expect(hasWatchActivity("org_0")).toBe(false);
|
||||
expect(hasWatchActivity("org_11")).toBe(true);
|
||||
});
|
||||
|
||||
describe("a corrupt key", () => {
|
||||
it("reads as nothing known when the value is not an array", () => {
|
||||
store.set(STORAGE_KEY, JSON.stringify({ org_1: true }));
|
||||
|
||||
expect(hasWatchActivity("org_1")).toBe(false);
|
||||
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the ids out of an array holding other things", () => {
|
||||
store.set(STORAGE_KEY, JSON.stringify([{ id: "org_1" }, "org_2", 7]));
|
||||
|
||||
expect(hasWatchActivity("org_1")).toBe(false);
|
||||
expect(hasWatchActivity("org_2")).toBe(true);
|
||||
|
||||
rememberWatchActivity("org_3");
|
||||
expect(store.get(STORAGE_KEY)).toBe(JSON.stringify(["org_2", "org_3"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldPollWakeFeed", () => {
|
||||
it("polls in a fresh browser the page load says has an unread wake", () => {
|
||||
expect(shouldPollWakeFeed({ serverUnreadWakes: 1, organizationId: "org_1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("stays quiet when neither the page load nor this browser knows of anything", () => {
|
||||
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("polls in a fresh browser whose only signal is an active watch", () => {
|
||||
// Created on another machine, nothing woken yet, no local marker.
|
||||
expect(
|
||||
shouldPollWakeFeed({
|
||||
serverUnreadWakes: 0,
|
||||
serverHasActiveWatches: true,
|
||||
organizationId: "org_1",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays quiet in a fresh browser with no wake and no active watch", () => {
|
||||
expect(
|
||||
shouldPollWakeFeed({
|
||||
serverUnreadWakes: 0,
|
||||
serverHasActiveWatches: false,
|
||||
organizationId: "org_1",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("polls without a reload once this browser sees a watch", () => {
|
||||
rememberWatchActivity("org_1");
|
||||
|
||||
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_1" })).toBe(true);
|
||||
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_2" })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Which organizations this browser has seen agent watches in. This is an accelerator, not the
|
||||
* gate: a watch created in this tab starts the poll without a reload. The ungated signals are the
|
||||
* unread count and the active-watch flag the page load carries — see {@link shouldPollWakeFeed}.
|
||||
* Shared through `localStorage`, so a watch created in one tab wakes the others.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "tdev:dashboard-agent:watching";
|
||||
|
||||
// Newest ids only, so the key can't grow unbounded.
|
||||
const MAX_REMEMBERED = 10;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
// Anything else under this key is another writer's; keep only what we can compare.
|
||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
|
||||
} catch {
|
||||
// Storage unavailable; treated as "nothing known yet".
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWatchActivity(organizationId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return read().includes(organizationId);
|
||||
}
|
||||
|
||||
/** Called whenever a watch shows up for this org: the poll starts from here. */
|
||||
export function rememberWatchActivity(organizationId: string): void {
|
||||
if (typeof window === "undefined" || hasWatchActivity(organizationId)) return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([...read(), organizationId].slice(-MAX_REMEMBERED))
|
||||
);
|
||||
} catch {
|
||||
// Same as the read. This tab still starts polling for the rest of the session.
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when nothing is left to be woken about. The current tab keeps polling for the rest of
|
||||
* the session; the next reload starts quiet.
|
||||
*/
|
||||
export function forgetWatchActivity(organizationId: string): void {
|
||||
if (typeof window === "undefined" || !hasWatchActivity(organizationId)) return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(read().filter((id) => id !== organizationId))
|
||||
);
|
||||
} catch {
|
||||
// Same as the read.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this browser should poll the wake feed. Both server signals come from the page load,
|
||||
* so a fresh browser polls without ever opening the panel: `serverUnreadWakes` for a wake that
|
||||
* already landed, `serverHasActiveWatches` for one created elsewhere that hasn't fired yet.
|
||||
*/
|
||||
export function shouldPollWakeFeed(params: {
|
||||
serverUnreadWakes: number;
|
||||
serverHasActiveWatches?: boolean;
|
||||
/** Chats holding work their owner hasn't seen, as the page load counted them. */
|
||||
serverUnreadWork?: number;
|
||||
/** This tab sent a turn that may still be running behind a closed panel. */
|
||||
turnInFlight?: boolean;
|
||||
organizationId: string;
|
||||
}): boolean {
|
||||
return (
|
||||
params.serverUnreadWakes > 0 ||
|
||||
params.serverHasActiveWatches === true ||
|
||||
(params.serverUnreadWork ?? 0) > 0 ||
|
||||
params.turnInFlight === true ||
|
||||
hasWatchActivity(params.organizationId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Fires when this browser learns of a watch, in this tab or — via `storage` — in another one. */
|
||||
export function subscribeWatchActivity(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === null || event.key === STORAGE_KEY) listener();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { WatchDraft } from "@internal/dashboard-agent-contracts";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NO_WATCH_CARD, watchCardReducer, type WatchCardState } from "./watch-card-state";
|
||||
|
||||
const draftFor = (note: string): WatchDraft =>
|
||||
({
|
||||
spec: { kind: "error_recurrence", fingerprint: note, checkEveryMinutes: 15, maxHours: 6, note },
|
||||
followUp: { investigateOnAttention: false, notifyExternally: false },
|
||||
}) as WatchDraft;
|
||||
|
||||
const run = (events: Parameters<typeof watchCardReducer>[1][], from = NO_WATCH_CARD) =>
|
||||
events.reduce<WatchCardState>(watchCardReducer, from);
|
||||
|
||||
const opened = () => run([{ type: "open", draft: draftFor("the TypeError"), requestId: "wreq_1" }]);
|
||||
|
||||
describe("a watch card belongs to the chat it was configured in", () => {
|
||||
it("abandons a half-configured card when the chat changes", () => {
|
||||
expect(run([{ type: "chat-changed" }], opened())).toEqual(NO_WATCH_CARD);
|
||||
});
|
||||
|
||||
it("lets go of the request id too, so the next card writes its own records", () => {
|
||||
const afterFailure = run(
|
||||
[
|
||||
{ type: "submitting", requestId: "wreq_1" },
|
||||
{ type: "failed", error: "nope" },
|
||||
],
|
||||
opened()
|
||||
);
|
||||
expect(afterFailure.requestId).toBe("wreq_1");
|
||||
expect(run([{ type: "chat-changed" }], afterFailure).requestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("abandons a card that was mid-submit when the chat changed", () => {
|
||||
const submitting = run([{ type: "submitting", requestId: "wreq_1" }], opened());
|
||||
expect(submitting.pending).toBe(true);
|
||||
expect(run([{ type: "chat-changed" }], submitting)).toEqual(NO_WATCH_CARD);
|
||||
});
|
||||
|
||||
it("clears the card once it has been submitted", () => {
|
||||
expect(run([{ type: "submitted" }], opened())).toEqual(NO_WATCH_CARD);
|
||||
expect(run([{ type: "dismissed" }], opened())).toEqual(NO_WATCH_CARD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the request id survives a retry", () => {
|
||||
it("keeps the id it was opened with across a failed submit", () => {
|
||||
const retried = run(
|
||||
[
|
||||
{ type: "submitting", requestId: "wreq_1" },
|
||||
{ type: "failed", error: "nope" },
|
||||
{ type: "submitting", requestId: "wreq_2" },
|
||||
],
|
||||
opened()
|
||||
);
|
||||
// A resubmit repairs the same server records; a fresh id would write a second pair.
|
||||
expect(retried.requestId).toBe("wreq_1");
|
||||
expect(retried.error).toBeNull();
|
||||
expect(retried.pending).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the edited draft, and edits nothing once the card is gone", () => {
|
||||
const edited = run([{ type: "edit", draft: draftFor("edited") }], opened());
|
||||
expect(edited.draft).toEqual(draftFor("edited"));
|
||||
expect(edited.requestId).toBe("wreq_1");
|
||||
expect(run([{ type: "edit", draft: draftFor("edited") }])).toEqual(NO_WATCH_CARD);
|
||||
});
|
||||
|
||||
it("opening a second card starts clean", () => {
|
||||
const reopened = run(
|
||||
[
|
||||
{ type: "failed", error: "nope" },
|
||||
{ type: "open", draft: draftFor("another"), requestId: "wreq_2" },
|
||||
],
|
||||
opened()
|
||||
);
|
||||
expect(reopened).toEqual({
|
||||
draft: draftFor("another"),
|
||||
requestId: "wreq_2",
|
||||
pending: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Structural guard, not behavioural proof: the reducer only sees a chat change if every path
|
||||
* that changes chat routes through `claimChatSlot`, which is also the only place the in-flight
|
||||
* open sequence is bumped.
|
||||
*/
|
||||
describe("every chat change goes through one door", () => {
|
||||
const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
it("bumps the open sequence in exactly one place, next to the card reset", () => {
|
||||
const bumps = panel.match(/openChatRequestSeq\.current\s*(\+\+|\+=)|\+\+openChatRequestSeq/g);
|
||||
expect(bumps).toHaveLength(1);
|
||||
const claim = panel.slice(
|
||||
panel.indexOf("const claimChatSlot = useCallback(() => {"),
|
||||
panel.indexOf("const openChat = useCallback(")
|
||||
);
|
||||
// Whitespace-tolerant: the formatter is free to reindent or rewrap the call.
|
||||
expect(claim).toMatch(/dispatchWatchCard\(\{\s*type:\s*"chat-changed",?\s*\}\);/);
|
||||
expect(claim).toContain("return ++openChatRequestSeq.current;");
|
||||
});
|
||||
|
||||
it("claims a slot before every setActive that lands in a different chat", () => {
|
||||
for (const caller of ["openChat", "createChat", "newChat", "submitWatch"]) {
|
||||
expect(panel).toMatch(new RegExp(`const ${caller} = useCallback\\(`));
|
||||
}
|
||||
// The watch's own landing chat: without the claim, an earlier open still matches its seq.
|
||||
const submit = panel.slice(panel.indexOf("const submitWatch = useCallback("));
|
||||
const claim = submit.indexOf("claimChatSlot();");
|
||||
// Whitespace-tolerant: the formatter is free to wrap the call across lines.
|
||||
const setActive = submit.search(/setActive\(\{\s*chatId:\s*data\.chatId/);
|
||||
expect(claim).toBeGreaterThan(-1);
|
||||
expect(setActive).toBeGreaterThan(claim);
|
||||
});
|
||||
|
||||
it("leaves no separate watch-draft state for a chat change to miss", () => {
|
||||
expect(panel).not.toContain("setWatchDraft");
|
||||
expect(panel).not.toContain("watchRequestId");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The panel's watch card, as a pure state machine.
|
||||
*
|
||||
* A card is configured against the chat that is open at the time and submitted against
|
||||
* whatever chat is open when `Start watching` is pressed, so it cannot outlive its chat:
|
||||
* every chat change abandons it, request id and all. The request id is what makes a retry
|
||||
* repair the same pair of server records instead of writing a second pair, so it is held
|
||||
* across a failure and dropped with the card.
|
||||
*/
|
||||
import type { WatchDraft } from "@internal/dashboard-agent-contracts";
|
||||
|
||||
export type WatchCardState = {
|
||||
draft: WatchDraft | null;
|
||||
requestId: string | undefined;
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const NO_WATCH_CARD: WatchCardState = {
|
||||
draft: null,
|
||||
requestId: undefined,
|
||||
pending: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export type WatchCardEvent =
|
||||
| { type: "open"; draft: WatchDraft; requestId: string }
|
||||
| { type: "edit"; draft: WatchDraft }
|
||||
| { type: "submitting"; requestId: string }
|
||||
| { type: "failed"; error: string }
|
||||
| { type: "submitted" }
|
||||
| { type: "dismissed" }
|
||||
| { type: "chat-changed" };
|
||||
|
||||
export function watchCardReducer(state: WatchCardState, event: WatchCardEvent): WatchCardState {
|
||||
switch (event.type) {
|
||||
case "open":
|
||||
return { draft: event.draft, requestId: event.requestId, pending: false, error: null };
|
||||
case "edit":
|
||||
return state.draft ? { ...state, draft: event.draft } : state;
|
||||
case "submitting":
|
||||
return {
|
||||
...state,
|
||||
requestId: state.requestId ?? event.requestId,
|
||||
pending: true,
|
||||
error: null,
|
||||
};
|
||||
case "failed":
|
||||
return { ...state, pending: false, error: event.error };
|
||||
case "submitted":
|
||||
case "dismissed":
|
||||
case "chat-changed":
|
||||
return NO_WATCH_CARD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WATCH_MAX_QUEUE_AGE_MINUTES,
|
||||
WATCH_MAX_QUEUE_THRESHOLD,
|
||||
WATCH_STALL_TICKS_DEFAULT,
|
||||
watchSpecSchema,
|
||||
type WatchSpec,
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
import { OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
|
||||
import {
|
||||
clampCadence,
|
||||
variantsOf,
|
||||
watchDraftError,
|
||||
watchDraftFor,
|
||||
withAgeMinutes,
|
||||
withCadence,
|
||||
withFollowUp,
|
||||
withThreshold,
|
||||
withVariant,
|
||||
withWindow,
|
||||
} from "./watch-card";
|
||||
import {
|
||||
watchConditionLabel,
|
||||
watchConfirmationBlockBody,
|
||||
watchDurationLabel,
|
||||
watchOneShotBlockBody,
|
||||
watchSubjectLabel,
|
||||
} from "~/presenters/v3/dashboardAgent";
|
||||
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("queue_oldest_age");
|
||||
expect(errorWatchRecommendation("error_a1b2c3d4").kind).toBe("error_recurrence");
|
||||
expect(healthWatchRecommendation("warn").kind).toBe("health_recovery");
|
||||
});
|
||||
|
||||
it("switches the queue recommendation to the drain once runs are already late", () => {
|
||||
const late = queueWatchRecommendation("email-sends", {
|
||||
oldestWaitMs: OLDEST_WAIT_WARNING_MS,
|
||||
});
|
||||
expect(late).toMatchObject({
|
||||
kind: "backlog_drain",
|
||||
queue: "email-sends",
|
||||
});
|
||||
expect(watchSpecSchema.safeParse(late).success).toBe(true);
|
||||
});
|
||||
|
||||
it("stays on the age SLA when the queue is merely busy, or the signal is missing", () => {
|
||||
expect(
|
||||
queueWatchRecommendation("email-sends", { oldestWaitMs: OLDEST_WAIT_WARNING_MS - 1 }).kind
|
||||
).toBe("queue_oldest_age");
|
||||
expect(queueWatchRecommendation("email-sends", { oldestWaitMs: null }).kind).toBe(
|
||||
"queue_oldest_age"
|
||||
);
|
||||
expect(queueWatchRecommendation("email-sends", {}).kind).toBe("queue_oldest_age");
|
||||
expect(queueWatchRecommendation("email-sends").kind).toBe("queue_oldest_age");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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("offers the whole run family and the whole queue family", () => {
|
||||
expect(variantsOf(runDraft())).toEqual(["run_start", "run_finished", "run_failed"]);
|
||||
expect(variantsOf(queueDraft())).toEqual([
|
||||
"backlog_drain",
|
||||
"queue_depth_above",
|
||||
"queue_depth_below",
|
||||
"queue_stalled",
|
||||
"queue_oldest_age",
|
||||
]);
|
||||
expect(variantsOf(watchDraftFor(errorWatchRecommendation("error_a1")))).toHaveLength(1);
|
||||
expect(variantsOf(watchDraftFor(healthWatchRecommendation("warn")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("carries the subject and window across a swap, and restates the note", () => {
|
||||
const draft = withWindow(runDraft(), 6);
|
||||
const failed = withVariant(draft, "run_failed");
|
||||
expect(failed.spec).toMatchObject({
|
||||
kind: "run_failed",
|
||||
runId: "run_abc123",
|
||||
maxHours: 6,
|
||||
note: "tell me if run run_abc123 fails",
|
||||
});
|
||||
});
|
||||
|
||||
it("restates the note when the threshold number changes", () => {
|
||||
const above = withThreshold(withVariant(queueDraft(), "queue_depth_above"), 500);
|
||||
// Same verb and same SLA format as the card's condition line: both come from
|
||||
// the presenter's one wording record.
|
||||
expect(above.spec.note).toBe("tell me if the email-sends queue goes above 500");
|
||||
const age = withAgeMinutes(withVariant(queueDraft(), "queue_oldest_age"), 90);
|
||||
expect(age.spec.note).toBe("tell me if runs in email-sends wait longer than 1h 30m");
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
it("gives every queue variant a submittable default and keeps the subject", () => {
|
||||
for (const kind of [
|
||||
"queue_depth_above",
|
||||
"queue_depth_below",
|
||||
"queue_stalled",
|
||||
"queue_oldest_age",
|
||||
] as const) {
|
||||
const swapped = withVariant(queueDraft(), kind);
|
||||
expect(swapped.spec).toMatchObject({ kind, queue: "email-sends" });
|
||||
expect(watchDraftError(swapped)).toBeNull();
|
||||
expect(watchSpecSchema.safeParse(swapped.spec).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries a typed threshold between the two threshold questions", () => {
|
||||
const above = withThreshold(withVariant(queueDraft(), "queue_depth_above"), 500);
|
||||
const below = withVariant(above, "queue_depth_below");
|
||||
expect(below.spec).toMatchObject({ kind: "queue_depth_below", threshold: 500 });
|
||||
});
|
||||
|
||||
it("keeps the stall count internal — the default, never a field", () => {
|
||||
const stalled = withVariant(queueDraft(), "queue_stalled");
|
||||
expect(stalled.spec).toMatchObject({ ticks: WATCH_STALL_TICKS_DEFAULT });
|
||||
expect(withThreshold(stalled, 5)).toEqual(stalled);
|
||||
expect(withAgeMinutes(stalled, 5)).toEqual(stalled);
|
||||
});
|
||||
});
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
it("refuses a half-typed threshold on the `below` variant too", () => {
|
||||
const draft = withThreshold(withVariant(queueDraft(), "queue_depth_below"), Number.NaN);
|
||||
expect(watchDraftError(draft)).toMatch(/whole number/i);
|
||||
});
|
||||
|
||||
it("refuses an SLA that is empty, zero, or longer than a watch can run", () => {
|
||||
const age = withVariant(queueDraft(), "queue_oldest_age");
|
||||
expect(watchDraftError(withAgeMinutes(age, Number.NaN))).toMatch(/whole number of minutes/i);
|
||||
expect(watchDraftError(withAgeMinutes(age, 0))).toMatch(/whole number of minutes/i);
|
||||
expect(watchDraftError(withAgeMinutes(age, WATCH_MAX_QUEUE_AGE_MINUTES + 1))).toMatch(
|
||||
/longer than a watch can run/i
|
||||
);
|
||||
expect(watchDraftError(withAgeMinutes(age, 30))).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores an SLA set on a kind that has none", () => {
|
||||
expect(withAgeMinutes(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("says the kind once, and names the error in full", () => {
|
||||
// Fingerprints are stored prefixed (`error_c4b4a797397a9c43`), so the raw value
|
||||
// would read "error error_c4b4a797397a9c43".
|
||||
expect(
|
||||
watchSubjectLabel({
|
||||
kind: "error_recurrence",
|
||||
fingerprint: "error_c4b4a797397a9c43",
|
||||
checkEveryMinutes: 5,
|
||||
maxHours: 0.5,
|
||||
})
|
||||
).toBe("error c4b4a797397a9c43");
|
||||
});
|
||||
|
||||
it("states the condition and the duration as §2.2 writes them", () => {
|
||||
const spec = queueWatchRecommendation("email-sends", { oldestWaitMs: OLDEST_WAIT_WARNING_MS });
|
||||
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");
|
||||
});
|
||||
|
||||
it("states each new queue condition the way the user reads it", () => {
|
||||
const below = withThreshold(withVariant(queueDraft(), "queue_depth_below"), 100);
|
||||
expect(watchConditionLabel(below.spec)).toBe("Until the queue is back below 100");
|
||||
|
||||
const stalled = withVariant(queueDraft(), "queue_stalled");
|
||||
expect(watchConditionLabel(stalled.spec)).toBe("If the queue stops moving");
|
||||
|
||||
const age = withAgeMinutes(withVariant(queueDraft(), "queue_oldest_age"), 90);
|
||||
expect(watchConditionLabel(age.spec)).toBe("If runs wait longer than 1h 30m");
|
||||
expect(watchSubjectLabel(age.spec)).toBe("email-sends");
|
||||
});
|
||||
|
||||
it("writes the confirmation as one sentence for every queue condition", () => {
|
||||
const stalled = withVariant(queueDraft(), "queue_stalled");
|
||||
expect(watchConfirmationBlockBody({ spec: stalled.spec, watchId: "w" }).headline).toBe(
|
||||
"Watching email-sends in case it stops moving."
|
||||
);
|
||||
|
||||
const age = withAgeMinutes(withVariant(queueDraft(), "queue_oldest_age"), 5);
|
||||
expect(watchConfirmationBlockBody({ spec: age.spec, watchId: "w" }).headline).toBe(
|
||||
"Watching email-sends in case runs wait longer than 5m."
|
||||
);
|
||||
|
||||
const below = withThreshold(withVariant(queueDraft(), "queue_depth_below"), 100);
|
||||
expect(watchConfirmationBlockBody({ spec: below.spec, watchId: "w" }).headline).toBe(
|
||||
"Watching email-sends until it is back below 100."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the persisted blocks (§2.2)", () => {
|
||||
it("states all four lifetime facts on a confirmation", () => {
|
||||
const body = watchConfirmationBlockBody({
|
||||
spec: queueWatchRecommendation("email-sends", { oldestWaitMs: OLDEST_WAIT_WARNING_MS }),
|
||||
watchId: "watch_1",
|
||||
});
|
||||
expect(body.outcome).toBe("watching");
|
||||
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, external: { status: "not_requested" } },
|
||||
});
|
||||
expect(body.followUp).toEqual(["If it turns out badly, I'll investigate straight away."]);
|
||||
});
|
||||
|
||||
it("says out loud when the email the user asked for couldn't be added", () => {
|
||||
const body = watchConfirmationBlockBody({
|
||||
spec: queueWatchRecommendation("email-sends"),
|
||||
watchId: "watch_1",
|
||||
followUp: { external: { status: "unavailable", reason: "email_alerts_not_configured" } },
|
||||
});
|
||||
expect(body.followUp).toEqual([
|
||||
"I couldn't add email notifications, so updates will appear in the dashboard only.",
|
||||
]);
|
||||
});
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* The watch card's state machine, kept pure so its rules are testable without a DOM.
|
||||
*
|
||||
* The card never invents a value the schema would reject: switching condition
|
||||
* variant re-clamps the cadence, and the window is always one of the offered
|
||||
* options. The option lists are read from contracts (`watchCadenceOptions`,
|
||||
* `WATCH_WINDOW_HOURS_OPTIONS`) rather than re-typed, so a picker cannot offer
|
||||
* something validation would refuse. Nothing here persists: a draft is client-side
|
||||
* until `Start watching` submits it.
|
||||
*/
|
||||
import {
|
||||
WATCH_DEFAULT_QUEUE_AGE_MINUTES,
|
||||
WATCH_DEFAULT_QUEUE_THRESHOLD,
|
||||
WATCH_MAX_HOURS,
|
||||
WATCH_MAX_QUEUE_AGE_MINUTES,
|
||||
WATCH_MAX_QUEUE_THRESHOLD,
|
||||
WATCH_STALL_TICKS_DEFAULT,
|
||||
WATCH_WINDOW_HOURS_OPTIONS,
|
||||
watchCadenceOptions,
|
||||
watchConditionVariants,
|
||||
watchSpecSchema,
|
||||
type WatchDraft,
|
||||
type WatchFollowUp,
|
||||
type WatchKind,
|
||||
type WatchSpec,
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
import { noteFor } from "~/presenters/v3/dashboardAgent";
|
||||
|
||||
/** A brand-new draft: the recommendation, with both opt-ins off. */
|
||||
export function watchDraftFor(spec: WatchSpec): WatchDraft {
|
||||
return { spec, followUp: { investigateOnAttention: false, notifyExternally: false } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest cadence this kind is allowed to poll at. 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, carrying everything else except the
|
||||
* note, which is restated to describe the new condition.
|
||||
*/
|
||||
export function withVariant(draft: WatchDraft, kind: WatchKind): WatchDraft {
|
||||
const next = variantSpec(draft, kind);
|
||||
if (next === draft.spec) return draft;
|
||||
return { ...draft, spec: { ...next, note: noteFor(next) } as WatchSpec };
|
||||
}
|
||||
|
||||
function variantSpec(draft: WatchDraft, kind: WatchKind): WatchSpec {
|
||||
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 { ...common, kind, runId } as WatchSpec;
|
||||
}
|
||||
case "backlog_drain": {
|
||||
const queue = "queue" in spec ? spec.queue : "";
|
||||
return { ...common, kind, queue } as WatchSpec;
|
||||
}
|
||||
case "queue_depth_above":
|
||||
case "queue_depth_below": {
|
||||
const queue = "queue" in spec ? spec.queue : "";
|
||||
// The number carries across the two threshold questions: someone who typed
|
||||
// 500 for "above" means the same 500 when they flip to "back below".
|
||||
const threshold = "threshold" in spec ? spec.threshold : WATCH_DEFAULT_QUEUE_THRESHOLD;
|
||||
return { ...common, kind, queue, threshold } as WatchSpec;
|
||||
}
|
||||
case "queue_stalled": {
|
||||
const queue = "queue" in spec ? spec.queue : "";
|
||||
// Ticks are not user-facing: the card never shows a field for them.
|
||||
const ticks = "ticks" in spec ? spec.ticks : WATCH_STALL_TICKS_DEFAULT;
|
||||
return { ...common, kind, queue, ticks } as WatchSpec;
|
||||
}
|
||||
case "queue_oldest_age": {
|
||||
const queue = "queue" in spec ? spec.queue : "";
|
||||
const thresholdMinutes =
|
||||
"thresholdMinutes" in spec ? spec.thresholdMinutes : WATCH_DEFAULT_QUEUE_AGE_MINUTES;
|
||||
return { ...common, kind, queue, thresholdMinutes } as WatchSpec;
|
||||
}
|
||||
// The kinds with no second question keep the draft untouched.
|
||||
default:
|
||||
return draft.spec;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The conditions this draft's picker offers, in order, including the current one.
|
||||
* A single-entry list means the kind has no second question and the card states
|
||||
* the condition as a fact instead of a choice.
|
||||
*/
|
||||
export function variantsOf(draft: WatchDraft): readonly WatchKind[] {
|
||||
return watchConditionVariants(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. No range checks here: a half-typed
|
||||
* field is a draft, and `watchDraftError` is what refuses to submit it.
|
||||
*/
|
||||
export function withThreshold(draft: WatchDraft, threshold: number): WatchDraft {
|
||||
if (draft.spec.kind !== "queue_depth_above" && draft.spec.kind !== "queue_depth_below") {
|
||||
return draft;
|
||||
}
|
||||
// The note quotes the number, so a new number restates the note.
|
||||
const spec = { ...draft.spec, threshold };
|
||||
return { ...draft, spec: { ...spec, note: noteFor(spec) } };
|
||||
}
|
||||
|
||||
/** The age SLA in minutes, as the user is typing it. Same rule as the threshold. */
|
||||
export function withAgeMinutes(draft: WatchDraft, thresholdMinutes: number): WatchDraft {
|
||||
if (draft.spec.kind !== "queue_oldest_age") return draft;
|
||||
// The note quotes the number, so a new number restates the note.
|
||||
const spec = { ...draft.spec, thresholdMinutes };
|
||||
return { ...draft, spec: { ...spec, note: noteFor(spec) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The two follow-up opt-ins, set independently. There is no way to express
|
||||
* "external instead of chat": in-chat delivery is not a choice, so it is not here.
|
||||
*/
|
||||
export function withFollowUp(draft: WatchDraft, patch: Partial<WatchFollowUp>): 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 sentence the card shows inline.
|
||||
*/
|
||||
export function watchDraftError(draft: WatchDraft): string | null {
|
||||
if (draft.spec.kind === "queue_depth_above" || draft.spec.kind === "queue_depth_below") {
|
||||
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.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.spec.kind === "queue_oldest_age") {
|
||||
const { thresholdMinutes } = draft.spec;
|
||||
if (!Number.isInteger(thresholdMinutes) || thresholdMinutes < 1) {
|
||||
return "Enter a whole number of minutes to watch for.";
|
||||
}
|
||||
if (thresholdMinutes > WATCH_MAX_QUEUE_AGE_MINUTES) {
|
||||
return `That's longer than a watch can run — ${WATCH_MAX_QUEUE_AGE_MINUTES} minutes is the most.`;
|
||||
}
|
||||
}
|
||||
|
||||
return watchSpecSchema.safeParse(draft.spec).success
|
||||
? null
|
||||
: "Something in this watch isn't valid. Check the duration and the condition.";
|
||||
}
|
||||
@@ -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("labels an error watch by its fingerprint, in full", () => {
|
||||
expect(
|
||||
watchChipLabel(
|
||||
chip({
|
||||
kind: "error_recurrence",
|
||||
fingerprint: "0123456789abcdef0123456789abcdef",
|
||||
note: "Tell me if the rate-limit error comes back.",
|
||||
maxHours: 12,
|
||||
checkEveryMinutes: 15,
|
||||
})
|
||||
)
|
||||
).toBe("0123456789abcdef0123456789abcdef");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The pure text of the watch UI: a chip's label and its tooltip.
|
||||
*
|
||||
* A chip has one line of room in a 380px panel, so the label names the thing being
|
||||
* watched and the icon carries the state. The label comes from the watch `identity`,
|
||||
* the same dedup key the store uses, so a chip cannot disagree with the store about
|
||||
* what it watches.
|
||||
*/
|
||||
import type { WatchStatus } from "@internal/dashboard-agent-contracts";
|
||||
|
||||
// The immediate-check wording lives in the presenter with the rest of the
|
||||
// user-facing copy. Re-exported here for chip callers.
|
||||
export { immediateWatchMessage } from "~/presenters/v3/dashboardAgent";
|
||||
|
||||
import {
|
||||
formatWatchCadence,
|
||||
shortFingerprint,
|
||||
watchIdentityValue,
|
||||
} from "~/presenters/v3/dashboardAgent";
|
||||
|
||||
export const WATCH_STATUS_LABEL: Record<WatchStatus, string> = {
|
||||
active: "watching",
|
||||
fired: "fired",
|
||||
expired: "expired",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
|
||||
/** Fingerprints are hashes — a chip shows just enough of one to tell them apart. */
|
||||
/**
|
||||
* 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":
|
||||
case "queue_stalled":
|
||||
return value || fallbackLabel(watch);
|
||||
// Identity is `{kind}:{queue}:{number}` here. The chip names the queue; the
|
||||
// number goes in the tooltip's note, where there is room for it.
|
||||
case "queue_depth_above":
|
||||
case "queue_depth_below":
|
||||
case "queue_oldest_age":
|
||||
return watchIdentityValue(watch.kind, watch.identity) || fallbackLabel(watch);
|
||||
case "error_recurrence":
|
||||
return value ? shortFingerprint(value) : 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(" · ");
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
WATCH_DEFAULT_QUEUE_AGE_MINUTES,
|
||||
type WatchSpec,
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
import { OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
|
||||
import { noteFor } from "~/presenters/v3/dashboardAgent";
|
||||
|
||||
/** Distributes over the spec union, so the kind stays discriminated. */
|
||||
type WithoutNote<T> = T extends unknown ? Omit<T, "note"> : never;
|
||||
|
||||
/** The note comes from the presenter, so a recommendation reads like an edited one. */
|
||||
function withNote(spec: WithoutNote<WatchSpec>): WatchSpec {
|
||||
const draft = { ...spec, note: "" } as WatchSpec;
|
||||
return { ...draft, note: noteFor(draft) };
|
||||
}
|
||||
|
||||
export function runWatchRecommendation(runFriendlyId: string): WatchSpec {
|
||||
return withNote({
|
||||
kind: "run_finished",
|
||||
runId: runFriendlyId,
|
||||
checkEveryMinutes: 1,
|
||||
maxHours: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The recommendation must be a condition that isn't true yet: an already-true watch
|
||||
* one-shots instead of watching. Past the wait threshold that means the drain, not the SLA.
|
||||
*/
|
||||
export function queueWatchRecommendation(
|
||||
queueName: string,
|
||||
context?: { oldestWaitMs?: number | null }
|
||||
): WatchSpec {
|
||||
const oldestWaitMs = context?.oldestWaitMs ?? null;
|
||||
if (oldestWaitMs !== null && oldestWaitMs >= OLDEST_WAIT_WARNING_MS) {
|
||||
return withNote({
|
||||
kind: "backlog_drain",
|
||||
queue: queueName,
|
||||
checkEveryMinutes: 5,
|
||||
maxHours: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return queueAgeWatchRecommendation(queueName);
|
||||
}
|
||||
|
||||
export function queueAgeWatchRecommendation(
|
||||
queueName: string,
|
||||
thresholdMinutes: number = WATCH_DEFAULT_QUEUE_AGE_MINUTES
|
||||
): WatchSpec {
|
||||
return withNote({
|
||||
kind: "queue_oldest_age",
|
||||
queue: queueName,
|
||||
thresholdMinutes,
|
||||
checkEveryMinutes: 5,
|
||||
maxHours: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorWatchRecommendation(errorFriendlyId: string): WatchSpec {
|
||||
return withNote({
|
||||
kind: "error_recurrence",
|
||||
fingerprint: errorFriendlyId,
|
||||
checkEveryMinutes: 5,
|
||||
maxHours: 6,
|
||||
});
|
||||
}
|
||||
|
||||
/** Only offered on a degraded report. `fromSeverity` is what the recovery is measured from. */
|
||||
export function healthWatchRecommendation(fromSeverity: "warn" | "crit"): WatchSpec {
|
||||
return withNote({
|
||||
kind: "health_recovery",
|
||||
report: "health",
|
||||
fromSeverity,
|
||||
checkEveryMinutes: 5,
|
||||
maxHours: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { storedQueueName } from "./queue-name";
|
||||
|
||||
describe("storedQueueName", () => {
|
||||
it("adds the prefix a task queue is stored with", () => {
|
||||
expect(storedQueueName({ type: "task", name: "my-task" })).toBe("task/my-task");
|
||||
});
|
||||
|
||||
it("keeps a prefix that is already there", () => {
|
||||
expect(storedQueueName({ type: "task", name: "task/my-task" })).toBe("task/my-task");
|
||||
});
|
||||
|
||||
// Malformed input reaches this, and the contract is one prefix, not "one fewer than it had".
|
||||
it("leaves one prefix however many the name arrived with", () => {
|
||||
expect(storedQueueName({ type: "task", name: "task/task/my-task" })).toBe("task/my-task");
|
||||
expect(storedQueueName({ type: "task", name: "task/task/task/my-task" })).toBe("task/my-task");
|
||||
});
|
||||
|
||||
it("leaves a custom queue alone, prefix-shaped name and all", () => {
|
||||
expect(storedQueueName({ type: "custom", name: "my-queue" })).toBe("my-queue");
|
||||
expect(storedQueueName({ type: "custom", name: "task/my-queue" })).toBe("task/my-queue");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* `TaskQueue.name` as the engine and the watch checks store it. A task queue keeps its
|
||||
* `task/` prefix there, and the presenters strip it for display only.
|
||||
*/
|
||||
export function storedQueueName(queue: { type: string; name: string }): string {
|
||||
return queue.type === "task" ? `task/${queue.name.replace(/^(?:task\/)+/, "")}` : queue.name;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Head-of-line wait at which a queue reads as stuck. */
|
||||
/** Head-of-line wait at which a queue reads as stuck. Shared by the queue page and the watch card. */
|
||||
export const OLDEST_WAIT_WARNING_MS = 5 * 60_000;
|
||||
|
||||
export type QueueCapacity = {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ApiAlertType = z.enum([
|
||||
"deployment_failure",
|
||||
"deployment_success",
|
||||
"error_group",
|
||||
"dashboard_agent_watch",
|
||||
]);
|
||||
|
||||
export type ApiAlertType = z.infer<typeof ApiAlertType>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* A view block or a resolved watch as plain text.
|
||||
*
|
||||
* The panel renders blocks as React; an email, a Slack message, a webhook body or
|
||||
* a log line cannot. Rather than each of those re-saying the block's contents in
|
||||
* its own words, they render it here. Pure, no React, no request context.
|
||||
*/
|
||||
import type { ViewBlock } from "@internal/dashboard-agent-contracts";
|
||||
import { presentResolvedWatch, watchNoteLine, type WatchResolvedInput } from "./watch-wording";
|
||||
|
||||
/** A labelled scalar the check observed. */
|
||||
export type TextFact = { label: string; value: string };
|
||||
|
||||
/** Facts as one `Label: value` line each. */
|
||||
export function renderFactLines(facts: readonly TextFact[]): string[] {
|
||||
return facts.map((fact) => `${fact.label}: ${fact.value}`);
|
||||
}
|
||||
|
||||
function lines(...parts: Array<string | null | undefined | false>): string {
|
||||
return parts
|
||||
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* One view block as plain text. Says what the card says and nothing more — no
|
||||
* surface may add a sentence of its own on top.
|
||||
*/
|
||||
export function renderBlockAsText(block: ViewBlock): string {
|
||||
switch (block.type) {
|
||||
case "watch_result":
|
||||
return lines(block.headline, block.lifetime, block.detail, ...block.followUp);
|
||||
|
||||
case "diagnosis":
|
||||
return lines(
|
||||
block.summary,
|
||||
`Likely cause: ${block.likelyCause}`,
|
||||
`Confidence: ${block.confidence}`,
|
||||
block.impact ? `Impact: ${block.impact}` : null,
|
||||
...block.evidence.map(
|
||||
(item) =>
|
||||
`Evidence (${item.type}): ${item.detail}${item.reference ? ` — ${item.reference}` : ""}`
|
||||
),
|
||||
...block.nextSteps.map((step, index) => `${index + 1}. ${step}`)
|
||||
);
|
||||
|
||||
case "investigation": {
|
||||
const state = block.investigation;
|
||||
return lines(
|
||||
state.title,
|
||||
state.headline,
|
||||
`Outcome: ${state.outcome} · severity ${state.severity} · confidence ${state.confidence}`,
|
||||
...state.hypotheses.map(
|
||||
(hypothesis) =>
|
||||
`${hypothesis.statement} — ${hypothesis.verdict}${
|
||||
hypothesis.finding ? `: ${hypothesis.finding}` : ""
|
||||
}`
|
||||
),
|
||||
state.remediation ? `Fix: ${state.remediation}` : null,
|
||||
...(state.checkNext ?? []).map((step) => `Check next: ${step}`),
|
||||
state.caveat ? `Caveat: ${state.caveat.message}` : null
|
||||
);
|
||||
}
|
||||
|
||||
case "report": {
|
||||
const { vm } = block;
|
||||
return lines(
|
||||
`${vm.title} report for ${vm.scope} (${vm.period}): ${vm.summary.severity}`,
|
||||
...vm.findings.map((finding) => `${finding.type} — ${finding.severity}: ${finding.reason}`)
|
||||
);
|
||||
}
|
||||
|
||||
// A chart is its shape, not its rows: the rows come from running the query.
|
||||
case "chart":
|
||||
return lines(`Chart: ${block.title ?? "untitled"} (${block.chartType})`, block.query);
|
||||
|
||||
case "actions":
|
||||
return lines(...block.actions.map((action) => `- ${action.label}`));
|
||||
|
||||
default: {
|
||||
const unreachable: never = block;
|
||||
throw new Error(`Unhandled view block: ${JSON.stringify(unreachable)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved watch as plain text: the fact, why it was being watched, then what the
|
||||
* resolving check saw. What the email body and the Slack message both say.
|
||||
*/
|
||||
export function renderResolvedWatchAsText(args: {
|
||||
resolved: WatchResolvedInput;
|
||||
note: string;
|
||||
facts: readonly TextFact[];
|
||||
}): string {
|
||||
const { headline } = presentResolvedWatch(args.resolved);
|
||||
return lines(headline, watchNoteLine(args.note), ...renderFactLines(args.facts));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// The dashboard agent's presenter: the one place a watch, a view block or a watch
|
||||
// result becomes English. Every surface (card, banner, toast, email, Slack,
|
||||
// webhook) imports from here.
|
||||
export * from "./block-text";
|
||||
export * from "./watch-wording";
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The watch vocabulary moved into the contracts package so the agent's own
|
||||
* deterministic narration says the same sentences the dashboard does — the agent
|
||||
* cannot import the webapp, and a second vocabulary would drift within a release.
|
||||
*
|
||||
* Re-exported here because every webapp surface imports the presenter, not contracts.
|
||||
*/
|
||||
export {
|
||||
formatWatchCadence,
|
||||
formatWatchDuration,
|
||||
formatWatchSla,
|
||||
formatWatchWait,
|
||||
formatWatchWindow,
|
||||
immediateWatchMessage,
|
||||
noteFor,
|
||||
presentResolvedWatch,
|
||||
WATCH_IN_CHAT_DELIVERY_LINE,
|
||||
WATCH_PRESENTATION_FALLBACK,
|
||||
WATCH_UPDATE_LABEL,
|
||||
shortFingerprint,
|
||||
watchConditionLabel,
|
||||
watchConditionWording,
|
||||
watchConfirmationBlockBody,
|
||||
watchDurationLabel,
|
||||
watchExternalNotificationLine,
|
||||
watchFollowUpLines,
|
||||
watchIdentityValue,
|
||||
watchLifetimeSentence,
|
||||
watchNoteLine,
|
||||
watchOneShotBlockBody,
|
||||
watchRequestSentence,
|
||||
watchSubjectLabel,
|
||||
watchSubline,
|
||||
watchTooltipLabel,
|
||||
type WatchConditionWording,
|
||||
type WatchPresentation,
|
||||
type WatchResolvedInput,
|
||||
type WatchSemanticIcon,
|
||||
} from "@internal/dashboard-agent-contracts";
|
||||
@@ -14,7 +14,8 @@ const DEFAULT_PERIOD = "1h";
|
||||
|
||||
/**
|
||||
* How long a finished report stays reusable. Capped at the liveness fresh window so a
|
||||
* cached report can never render "fresh" while its telemetry is already stale.
|
||||
* cached report can never render "fresh" while its telemetry is already stale, and it
|
||||
* stays under the watch tick cadence.
|
||||
*/
|
||||
export const REPORT_CACHE_TTL_MS = HEALTH_THRESHOLDS.liveness.freshMs;
|
||||
|
||||
|
||||
+18
-2
@@ -53,9 +53,13 @@ export const meta = pageMeta("New alert");
|
||||
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)
|
||||
@@ -456,6 +460,18 @@ export default function Page() {
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckboxWithLabel
|
||||
name={alertTypes.name}
|
||||
id="DASHBOARD_AGENT_WATCH"
|
||||
value="DASHBOARD_AGENT_WATCH"
|
||||
variant="simple/small"
|
||||
label="Dashboard agent watches"
|
||||
className="pr-0"
|
||||
/>
|
||||
<InfoIconTooltip content="You'll receive an alert when a watch you set up with the dashboard agent fires." />
|
||||
</div>
|
||||
|
||||
<FormError id={alertTypes.errorId}>{alertTypes.errors}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
|
||||
+2
@@ -570,6 +570,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}`);
|
||||
}
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
@@ -586,7 +588,7 @@ function ErrorDetailSidebar({
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 py-2">
|
||||
<Header2 className="truncate">Details</Header2>
|
||||
{/* Self-hides when the agent isn't available. */}
|
||||
{/* Both buttons self-hide when the agent isn't available. */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<InvestigateButton
|
||||
prompt={errorGroupPrompt(
|
||||
@@ -595,6 +597,9 @@ function ErrorDetailSidebar({
|
||||
)}
|
||||
label="Investigate this error"
|
||||
/>
|
||||
<WatchButton
|
||||
spec={errorWatchRecommendation(ErrorId.toFriendlyId(errorGroup.fingerprint))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
|
||||
+12
-3
@@ -8,6 +8,9 @@ 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 { storedQueueName } from "~/components/queues/queue-name";
|
||||
import { isQueueDegraded, OLDEST_WAIT_WARNING_MS } from "~/components/queues/queue-thresholds";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
@@ -121,7 +124,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
}
|
||||
|
||||
const queue = retrieve.queue;
|
||||
const fullName = queue.type === "task" ? `task/${queue.name}` : queue.name;
|
||||
const fullName = storedQueueName(queue);
|
||||
|
||||
const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId);
|
||||
|
||||
@@ -333,14 +336,20 @@ export default function Page() {
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
shortcut={{ key: "d" }}
|
||||
/>
|
||||
{/* Self-hides when the agent isn't available. */}
|
||||
{/* Both buttons self-hide when the agent isn't available. Watch is
|
||||
pre-filled with this queue's recommendation. */}
|
||||
{degraded ? (
|
||||
<InvestigateButton
|
||||
prompt={queueBacklogPrompt(queue.name)}
|
||||
prompt={queueBacklogPrompt(fullName)}
|
||||
variant="secondary"
|
||||
tooltip="Ask why this queue is backed up"
|
||||
/>
|
||||
) : null}
|
||||
{/* A paused queue can't drain or grow, so every watch it could offer is a
|
||||
promise nothing will keep until someone resumes it. */}
|
||||
{queue.paused ? null : (
|
||||
<WatchButton spec={queueWatchRecommendation(fullName, { oldestWaitMs })} />
|
||||
)}
|
||||
<QueueOverrideConcurrencyButton
|
||||
queue={queue}
|
||||
environmentConcurrencyLimit={environmentConcurrencyLimit}
|
||||
|
||||
+42
-1
@@ -1,8 +1,14 @@
|
||||
import {
|
||||
countChatsWithUnreadWork,
|
||||
readDashboardAgentWakeActivity,
|
||||
type DashboardAgentWakeActivity,
|
||||
} from "@internal/dashboard-agent-db";
|
||||
import { Outlet, useLoaderData } from "@remix-run/react";
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
|
||||
import { prisma } from "~/db.server";
|
||||
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
|
||||
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
|
||||
@@ -96,19 +102,54 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
})
|
||||
: null;
|
||||
|
||||
// One narrow read per page load, so the wake signal reaches a browser that has never opened
|
||||
// the panel — including one whose watch hasn't fired yet. The poll never asks for this.
|
||||
let dashboardAgentActivity: DashboardAgentWakeActivity = {
|
||||
unreadWakes: 0,
|
||||
hasActiveWatches: false,
|
||||
};
|
||||
let dashboardAgentUnreadWork = 0;
|
||||
if (hasDashboardAgentAccess) {
|
||||
try {
|
||||
[dashboardAgentActivity, dashboardAgentUnreadWork] = await Promise.all([
|
||||
readDashboardAgentWakeActivity(dashboardAgentDb, {
|
||||
organizationId: project.organization.id,
|
||||
userId: user.id,
|
||||
}),
|
||||
countChatsWithUnreadWork(dashboardAgentDb, {
|
||||
organizationId: project.organization.id,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
// The dashboard must load even when the agent's store doesn't answer.
|
||||
logger.error("Failed to read dashboard agent wake activity", { error });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
hasDashboardAgentAccess,
|
||||
promotedDashboardAgentPrompt,
|
||||
dashboardAgentActivity,
|
||||
dashboardAgentUnreadWork,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { hasDashboardAgentAccess, promotedDashboardAgentPrompt } = useLoaderData<typeof loader>();
|
||||
const {
|
||||
hasDashboardAgentAccess,
|
||||
promotedDashboardAgentPrompt,
|
||||
dashboardAgentActivity,
|
||||
dashboardAgentUnreadWork,
|
||||
} = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<DashboardAgent
|
||||
hasAccess={hasDashboardAgentAccess}
|
||||
promotedPrompt={promotedDashboardAgentPrompt ?? undefined}
|
||||
initialUnreadWakes={dashboardAgentActivity.unreadWakes}
|
||||
initialUnreadWork={dashboardAgentUnreadWork}
|
||||
hasActiveWatches={dashboardAgentActivity.hasActiveWatches}
|
||||
>
|
||||
<Outlet />
|
||||
</DashboardAgent>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { resolveAgentAlertContext } from "~/services/dashboardAgentAlertContext.server";
|
||||
import { unsubscribeChannelFromWatchAlerts } from "~/services/dashboardAgentWatchAlerts.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server";
|
||||
|
||||
/**
|
||||
* `DELETE /api/v1/dashboard-agent/alerts/:channelId` — stop alerting this channel
|
||||
* when a watch fires. The channel is looked up scoped to the chat's project.
|
||||
*/
|
||||
|
||||
const ParamsSchema = z.object({ channelId: z.string().min(1) });
|
||||
|
||||
const BodySchema = z.object({
|
||||
chatId: z.string().min(1),
|
||||
environmentId: z.string().min(1).optional(),
|
||||
projectRef: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "DELETE") {
|
||||
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 turn's environment scope is the authority for the chat's project below.
|
||||
const environmentId = authentication.userActor.environmentId;
|
||||
if (!environmentId) {
|
||||
return json(
|
||||
{ error: "This chat has no environment context.", code: "invalid_target" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
if (!parsedParams.success) return json({ error: "Invalid params" }, { status: 400 });
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = BodySchema.safeParse(rawBody);
|
||||
if (!parsedBody.success) {
|
||||
return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
const body = parsedBody.data;
|
||||
|
||||
try {
|
||||
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,
|
||||
// A project is shared by every member, so the caller's own address is part of the scope.
|
||||
organizationId: context.environment.organizationId,
|
||||
ownerUserId: userId,
|
||||
});
|
||||
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 });
|
||||
} catch (error) {
|
||||
logger.error("Failed to unsubscribe a channel from dashboard agent watch alerts", {
|
||||
error,
|
||||
userId,
|
||||
environmentId,
|
||||
channelId: parsedParams.data.channelId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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,
|
||||
subscribeChannelToWatchAlerts,
|
||||
watchAlertDeduplicationKey,
|
||||
} from "~/services/dashboardAgentWatchAlerts.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server";
|
||||
|
||||
/**
|
||||
* `GET` lists this chat's project's watch alerts; `POST` subscribes the user's email. Only
|
||||
* the agent's delegated user-actor token is accepted, and the environment comes from it.
|
||||
*/
|
||||
|
||||
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"),
|
||||
/** May only be the authenticated user's own account email. */
|
||||
email: z.string().email().optional(),
|
||||
environmentId: z.string().min(1).optional(),
|
||||
projectRef: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** A token without an environment scope is unusable here. */
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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 rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = CreateBodySchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return json({ error: "Invalid request", code: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Only the signed-in user's own account email may be subscribed. Read off the
|
||||
// primary: 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 channel = await subscribeChannelToWatchAlerts({
|
||||
userId,
|
||||
email,
|
||||
// Stable per (email, project), so asking twice re-enables one channel and asking from
|
||||
// a second environment adds that environment to it.
|
||||
deduplicationKey: watchAlertDeduplicationKey(email),
|
||||
environmentType: environment.type,
|
||||
project: environment.project,
|
||||
});
|
||||
|
||||
return json({ id: channel.id, type: channel.type, target: email, enabled: channel.enabled });
|
||||
} catch (error) {
|
||||
// A thrown Response is Remix control flow, not a failure to report.
|
||||
if (error instanceof Response) throw error;
|
||||
logger.error("Failed to create a dashboard agent watch alert channel", {
|
||||
error,
|
||||
userId,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.project.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
cancelWatch,
|
||||
getWatch,
|
||||
recordWatchAttempt,
|
||||
recordWatchCheck,
|
||||
} from "@internal/dashboard-agent-db";
|
||||
import { z } from "zod";
|
||||
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { checkWatch, previousCheckFacts } from "~/services/dashboardAgentWatchChecks";
|
||||
import { watchCheckDeps } from "~/services/dashboardAgentWatchChecks.server";
|
||||
import {
|
||||
armDashboardAgentWatchBatch,
|
||||
authorizeWatchEnvironment,
|
||||
} from "~/services/dashboardAgentWatches.server";
|
||||
import {
|
||||
WATCH_TOKEN_GRACE_MS,
|
||||
bearerToken,
|
||||
verifyWatchTokenFromRequest,
|
||||
} from "~/services/dashboardAgentWatchToken.server";
|
||||
|
||||
/**
|
||||
* Private per-watch check. The token only names a watch; the row is the authority on
|
||||
* lifecycle and its snapshot, and this route transitions nothing and advances no tick.
|
||||
*/
|
||||
|
||||
const ParamsSchema = z.object({ watchId: z.string().min(1) });
|
||||
|
||||
// obs-map-disable error-classification -- arming the chain is best-effort: every error means the same thing, retry next check
|
||||
|
||||
/** Best-effort: a chain that couldn't be armed returns `false` and is retried next check. */
|
||||
async function ensureBatchChain(watch: {
|
||||
id: string;
|
||||
environmentId: string;
|
||||
spec: { checkEveryMinutes: number };
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const { running } = await armDashboardAgentWatchBatch({
|
||||
environmentId: watch.environmentId,
|
||||
cadenceMinutes: watch.spec.checkEveryMinutes,
|
||||
});
|
||||
return running;
|
||||
} catch (error) {
|
||||
logger.error("Dashboard agent watch check: couldn't arm the batch chain", {
|
||||
watchId: watch.id,
|
||||
environmentId: watch.environmentId,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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 403, not 401.
|
||||
if (claims.watchId !== watchId) {
|
||||
return json({ error: "Not allowed for this watch", code: "watch_mismatch" }, { status: 403 });
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
rawBody = raw.length > 0 ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = BodySchema.safeParse(rawBody);
|
||||
if (!parsedBody.success) return json({ error: "Invalid request body" }, { status: 400 });
|
||||
const body = parsedBody.data;
|
||||
|
||||
const watch = await getWatch(dashboardAgentDb, { id: watchId });
|
||||
if (!watch) {
|
||||
return json({ error: "Watch not found", code: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Terminal watches are 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, inside the token's grace.
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-authorize the initiating user before any environment data is read.
|
||||
const authorization = await authorizeWatchEnvironment({
|
||||
userId: watch.userId,
|
||||
organizationId: watch.organizationId,
|
||||
projectId: watch.projectId,
|
||||
environmentId: watch.environmentId,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
// A watch must not outlive the access it was created with. Never notified.
|
||||
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),
|
||||
// A tick that couldn't read anything freezes a streak instead of resetting it.
|
||||
{ now, since, previous: previousCheckFacts(watch.lastResult) },
|
||||
(error) =>
|
||||
logger.error("Dashboard agent watch check failed", {
|
||||
error,
|
||||
watchId,
|
||||
userId: watch.userId,
|
||||
organizationId: watch.organizationId,
|
||||
projectId: watch.projectId,
|
||||
environmentId: watch.environmentId,
|
||||
})
|
||||
);
|
||||
|
||||
// Only a real evaluation is recorded, final or not: `unavailable` means nothing was read,
|
||||
// so writing it would move `lastCheckedAt` and overwrite the facts a streak lives in.
|
||||
// Guarded on `active`, so a concurrent fire/expire wins and this no-ops.
|
||||
if (outcome.result !== "unavailable") {
|
||||
await recordWatchCheck(dashboardAgentDb, {
|
||||
id: watchId,
|
||||
lastResult: {
|
||||
result: outcome.result,
|
||||
facts: outcome.facts,
|
||||
observed: outcome.observed,
|
||||
final: body.final === true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Looked at, not checked: the fairness key moves and nothing else does.
|
||||
await recordWatchAttempt(dashboardAgentDb, { id: watchId });
|
||||
}
|
||||
|
||||
const batched = await ensureBatchChain(watch);
|
||||
|
||||
// `observed` travels with the verdict so no delivery surface re-reads the source.
|
||||
return json({
|
||||
result: outcome.result,
|
||||
facts: outcome.facts,
|
||||
observed: outcome.observed,
|
||||
batched,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Dashboard agent watch check tick failed", {
|
||||
error,
|
||||
watchId,
|
||||
userId: watch.userId,
|
||||
organizationId: watch.organizationId,
|
||||
projectId: watch.projectId,
|
||||
environmentId: watch.environmentId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
claimWatchAlertDispatch,
|
||||
getWatch,
|
||||
releaseWatchAlertDispatch,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
* The watcher task reports a fired watch. The row is the authority on whether it fired, and
|
||||
* the initiating user is re-authorized against its snapshot before any alert is sent.
|
||||
*/
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
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.
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const claimed = await claimWatchAlertDispatch(dashboardAgentDb, {
|
||||
id: watch.id,
|
||||
terminalStatus: "fired",
|
||||
});
|
||||
if (!claimed) {
|
||||
logger.info("Dashboard agent watch fired callback repeated; no second alert", { watchId });
|
||||
return json({ ok: true, alerted: false });
|
||||
}
|
||||
|
||||
try {
|
||||
await enqueueWatchFiredAlert(watch, "fired");
|
||||
} catch (error) {
|
||||
await releaseWatchAlertDispatch(dashboardAgentDb, { id: watch.id, terminalStatus: "fired" });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return json({ ok: true, alerted: true });
|
||||
} catch (error) {
|
||||
logger.error("Dashboard agent watch fire callback failed", {
|
||||
error,
|
||||
watchId,
|
||||
userId: watch.userId,
|
||||
organizationId: watch.organizationId,
|
||||
projectId: watch.projectId,
|
||||
environmentId: watch.environmentId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { getWatch, isTerminalWatchStatus } 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 {
|
||||
kickWatchInvestigation,
|
||||
watchWantsInvestigation,
|
||||
} from "~/services/dashboardAgentWatchInvestigate.server";
|
||||
import { authorizeWatchEnvironment } from "~/services/dashboardAgentWatches.server";
|
||||
import {
|
||||
bearerToken,
|
||||
verifyWatchTokenFromRequest,
|
||||
} from "~/services/dashboardAgentWatchToken.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
/**
|
||||
* The watcher task reports a delivered wake for a pre-approved investigation. The caller's
|
||||
* body is ignored: consent, outcome, user and environment all come off the row.
|
||||
*/
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (!isTerminalWatchStatus(watch.status)) {
|
||||
return json(
|
||||
{ error: `This watch is ${watch.status}`, code: "not_resolved", status: watch.status },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!watchWantsInvestigation(watch)) {
|
||||
// No consent, or an outcome consent doesn't cover: the wake was the whole delivery.
|
||||
return json({ ok: true, investigating: false });
|
||||
}
|
||||
|
||||
const authorization = await authorizeWatchEnvironment({
|
||||
userId: watch.userId,
|
||||
organizationId: watch.organizationId,
|
||||
projectId: watch.projectId,
|
||||
environmentId: watch.environmentId,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
logger.info("Dashboard agent watch resolved, but access was revoked; no investigation", {
|
||||
watchId,
|
||||
});
|
||||
return json(
|
||||
{ error: "Access to this environment was revoked", code: "access_revoked" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Never an error to the caller: the wake is already delivered and marked, so a failed
|
||||
// kick must not make the watcher retry. The stale-investigation sweep settles it.
|
||||
try {
|
||||
await kickWatchInvestigation({ watch, environment: authorization.environment });
|
||||
} catch (error) {
|
||||
// A thrown Response is Remix control flow, not a failed kick.
|
||||
if (error instanceof Response) throw error;
|
||||
logger.error("Dashboard agent watch investigation could not be started", {
|
||||
error,
|
||||
watchId,
|
||||
userId: watch.userId,
|
||||
organizationId: watch.organizationId,
|
||||
projectId: watch.projectId,
|
||||
environmentId: watch.environmentId,
|
||||
});
|
||||
return json({ ok: true, investigating: false, code: "kick_failed" });
|
||||
}
|
||||
|
||||
return json({ ok: true, investigating: true });
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { runWatchBatchCheck } from "~/services/dashboardAgentWatchBatch.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import {
|
||||
bearerToken,
|
||||
verifyWatchBatchTokenFromRequest,
|
||||
} from "~/services/dashboardAgentWatchToken.server";
|
||||
|
||||
/**
|
||||
* Private batch check: one call per (environment, cadence) group per cadence. The token
|
||||
* names the group, the body names the tick. `runWatchBatchCheck` documents the rest.
|
||||
*/
|
||||
|
||||
const BodySchema = z.object({
|
||||
environmentId: z.string().min(1),
|
||||
cadenceMinutes: z.number().int().positive(),
|
||||
epoch: z.number().int().nonnegative(),
|
||||
tick: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const token = bearerToken(request);
|
||||
if (!token) {
|
||||
return json(
|
||||
{ error: "Invalid or missing access token", code: "unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const claims = await verifyWatchBatchTokenFromRequest(token);
|
||||
if (!claims) {
|
||||
return json(
|
||||
{ error: "Invalid or missing access token", code: "unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
rawBody = raw.length > 0 ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = BodySchema.safeParse(rawBody);
|
||||
if (!parsedBody.success) return json({ error: "Invalid request body" }, { status: 400 });
|
||||
const body = parsedBody.data;
|
||||
|
||||
// A valid token for a different group is 403, not 401.
|
||||
if (
|
||||
claims.environmentId !== body.environmentId ||
|
||||
claims.cadenceMinutes !== body.cadenceMinutes
|
||||
) {
|
||||
return json({ error: "Not allowed for this group", code: "group_mismatch" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
return json(await runWatchBatchCheck(body));
|
||||
} catch (error) {
|
||||
logger.error("Dashboard agent watch batch check failed", {
|
||||
error,
|
||||
environmentId: body.environmentId,
|
||||
cadenceMinutes: body.cadenceMinutes,
|
||||
epoch: body.epoch,
|
||||
tick: body.tick,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { watchSpecSchema } from "@internal/dashboard-agent-contracts";
|
||||
import { z } from "zod";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { resolveWatchEmailAlertsState } from "~/services/dashboardAgentWatchAlerts.server";
|
||||
import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.server";
|
||||
import {
|
||||
authorizeWatchEnvironmentById,
|
||||
createDashboardAgentWatch,
|
||||
resolveChatWatchContext,
|
||||
} from "~/services/dashboardAgentWatches.server";
|
||||
import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server";
|
||||
|
||||
/**
|
||||
* Programmatic watch creation (MCP). Only the agent's delegated user-actor token is
|
||||
* accepted, and the environment comes from it, never the body or the chat's stored context.
|
||||
*/
|
||||
|
||||
const BodySchema = z.object({
|
||||
spec: watchSpecSchema,
|
||||
chatId: z.string().min(1),
|
||||
/** Consent for the wake turn to open an investigation. Off unless explicitly sent. */
|
||||
investigateOnAttention: z.boolean().optional(),
|
||||
/**
|
||||
* Only checked against the token's environment scope, never used in its place.
|
||||
* `environmentId` is the canonical `RuntimeEnvironment.id`, not a slug.
|
||||
*/
|
||||
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. There is no trusted fallback.
|
||||
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 rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid watch request", code: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = BodySchema.safeParse(rawBody);
|
||||
if (!parsedBody.success) {
|
||||
return json({ error: "Invalid watch request", code: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
const parsed = parsedBody.data;
|
||||
|
||||
// Refuse a body naming a different environment rather than silently picking 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 {
|
||||
// A chat this user doesn't own does not exist here.
|
||||
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.
|
||||
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) {
|
||||
return json(
|
||||
{
|
||||
error: result.error,
|
||||
code: result.code,
|
||||
...(result.existingId ? { existingId: result.existingId } : {}),
|
||||
},
|
||||
{ status: watchErrorStatus(result.code) }
|
||||
);
|
||||
}
|
||||
|
||||
// One-shot: the immediate check answered, so there is no watch row and no id.
|
||||
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 resolveWatchEmailAlertsState({ userId, environment }),
|
||||
...(result.unavailable ? { unavailable: true } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
// A thrown Response is Remix control flow, not a failure to report.
|
||||
if (error instanceof Response) throw error;
|
||||
logger.error("Failed to create a dashboard agent watch", {
|
||||
error,
|
||||
userId,
|
||||
environmentId,
|
||||
chatId: parsed.chatId,
|
||||
});
|
||||
return json({ error: "Internal Server Error", code: "internal" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
triggerSource: true,
|
||||
createdAt: true,
|
||||
payloadSchema: true,
|
||||
queueConfig: true,
|
||||
},
|
||||
orderBy: {
|
||||
slug: "asc",
|
||||
@@ -100,6 +101,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
triggerSource: task.triggerSource,
|
||||
createdAt: task.createdAt,
|
||||
payloadSchema: task.payloadSchema,
|
||||
queueConfig: task.queueConfig,
|
||||
})),
|
||||
},
|
||||
urls,
|
||||
|
||||
@@ -14,6 +14,9 @@ export const loader = createLoaderApiRoute(
|
||||
queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")),
|
||||
}),
|
||||
searchParams: SearchParamsSchema,
|
||||
// The agent's environment JWT reads a queue's own row — name, depth, limit, paused —
|
||||
// the way it already reads that queue's metrics. The `queues` scope still gates it.
|
||||
allowJWT: true,
|
||||
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
|
||||
authorization: {
|
||||
action: "read",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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 { prisma } from "~/db.server";
|
||||
import { verifyUnsubscribeToken } from "~/services/dashboardAgentAlertUnsubscribeToken.server";
|
||||
import {
|
||||
DASHBOARD_AGENT_WATCH_ALERT_TYPE,
|
||||
unsubscribeChannelFromWatchAlerts,
|
||||
} from "~/services/dashboardAgentWatchAlerts.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
/**
|
||||
* The unsubscribe link in a watch alert email. The signed token is the whole authorization
|
||||
* and names one channel; GET confirms and POST acts, so a link preview can't unsubscribe.
|
||||
*/
|
||||
|
||||
const ParamsSchema = z.object({ channelId: z.string().min(1) });
|
||||
|
||||
async function authorize(request: Request, params: Record<string, unknown>) {
|
||||
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 POST needs the token, and a bare `<Form method="post">` drops search params.
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read only for the failure log. The unsubscribe does its own scoped lookup.
|
||||
const channel = await prisma.projectAlertChannel.findFirst({
|
||||
where: { id: claims.channelId },
|
||||
select: { projectId: true },
|
||||
});
|
||||
|
||||
try {
|
||||
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 });
|
||||
} catch (error) {
|
||||
logger.error("Failed to turn off watch alerts from an email link", {
|
||||
error,
|
||||
channelId: claims.channelId,
|
||||
projectId: channel?.projectId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { valid, formAction } = useTypedLoaderData<typeof loader>();
|
||||
const result = useTypedActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
|
||||
if (result?.success) {
|
||||
return (
|
||||
<Shell title="Alerts turned off">
|
||||
<Paragraph spacing>
|
||||
{result.channelName} will no longer be alerted when a watch fires. You can turn it back on
|
||||
from the Alerts page in your project.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/medium" to={rootPath()}>
|
||||
Dashboard
|
||||
</LinkButton>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!valid || result?.success === false) {
|
||||
return (
|
||||
<Shell title="Link no longer valid">
|
||||
<Paragraph spacing>
|
||||
{result?.success === false
|
||||
? result.message
|
||||
: "This link is no longer valid. You can manage alerts from the Alerts page in your project."}
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/medium" to={rootPath()}>
|
||||
Dashboard
|
||||
</LinkButton>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell title="Turn off watch alerts?">
|
||||
<Paragraph spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
<Form method="post" action={formAction}>
|
||||
<Button variant="primary/medium" disabled={isLoading}>
|
||||
{isLoading ? "Turning off…" : "Turn off these alerts"}
|
||||
</Button>
|
||||
</Form>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<MainCenteredContainer className="max-w-88">
|
||||
<div>
|
||||
<FormTitle
|
||||
LeadingIcon={<EnvelopeIcon className="size-6 text-cyan-500" />}
|
||||
title={title}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
+216
-13
@@ -1,16 +1,23 @@
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
chatExists,
|
||||
countUnreadWatchWakes,
|
||||
countChatsWithUnreadWork,
|
||||
countUserMessages,
|
||||
createChat,
|
||||
getChatMessages,
|
||||
getSession,
|
||||
getWatch,
|
||||
listChatIdsWithOpenInvestigations,
|
||||
listChatIdsWithUnreadWakes,
|
||||
listChats,
|
||||
markChatRead,
|
||||
readWatchWakeFeed,
|
||||
renameChat,
|
||||
setChatPinned,
|
||||
softDeleteChat,
|
||||
} from "@internal/dashboard-agent-db";
|
||||
import { 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";
|
||||
@@ -26,8 +33,16 @@ import { $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
authorizeWatchEnvironmentById,
|
||||
cancelDashboardAgentWatch,
|
||||
deleteChatWithWatches,
|
||||
listActiveWatchesForChats,
|
||||
submitDashboardAgentWatch,
|
||||
} from "~/services/dashboardAgentWatches.server";
|
||||
import {
|
||||
dashboardAgentApiOrigin,
|
||||
dashboardAgentWakeFeedCounter,
|
||||
isDashboardAgentConfigured,
|
||||
mintDashboardAgentToken,
|
||||
mintDashboardAgentUserActorToken,
|
||||
@@ -35,6 +50,7 @@ import {
|
||||
startDashboardAgentSession,
|
||||
} from "~/services/dashboardAgent.server";
|
||||
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
|
||||
import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.server";
|
||||
import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server";
|
||||
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -54,8 +70,11 @@ const ActionBody = z.object({
|
||||
"rename",
|
||||
"pin",
|
||||
"delete",
|
||||
"read",
|
||||
"resolve",
|
||||
"resolve-many",
|
||||
"watch-cancel",
|
||||
"watch-create",
|
||||
]),
|
||||
// Omitted for `create` (the server generates it); required for the rest.
|
||||
chatId: z.string().min(1).optional(),
|
||||
@@ -68,10 +87,17 @@ const ActionBody = z.object({
|
||||
uri: z.string().optional(),
|
||||
// A JSON array of `trigger://` URIs, for `resolve-many`.
|
||||
uris: 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(),
|
||||
// Stable per card submission, so a retried `watch-create` repairs instead of repeating.
|
||||
// Required for `watch-create`: see the check in that branch.
|
||||
clientRequestId: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
// History list by default. `?chatId=` returns the stored transcript plus session,
|
||||
// `?quota=1` the message count.
|
||||
// `?unread=1` the unread wake count and recent wakes, `?quota=1` the message count.
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
@@ -90,6 +116,38 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
// The wake poll runs once a minute per open tab, so it reads only the org id it needs
|
||||
// and asks the agent DB one question. The list is recent deliveries, not unread ones;
|
||||
// the client dedupes by id.
|
||||
if (searchParams.get("unread") === "1") {
|
||||
dashboardAgentWakeFeedCounter.inc();
|
||||
const scoped = await $replica.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
organization: { slug: organizationSlug, members: { some: { userId } } },
|
||||
},
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (!scoped) return json({ error: "Project not found" }, { status: 404 });
|
||||
|
||||
const [feed, unreadWork] = await Promise.all([
|
||||
readWatchWakeFeed(dashboardAgentDb, {
|
||||
organizationId: scoped.organizationId,
|
||||
userId,
|
||||
deliveredAfter: new Date(Date.now() - 15 * 60 * 1000),
|
||||
}),
|
||||
// The dot has two sources; the poll is where a closed panel learns about either.
|
||||
countChatsWithUnreadWork(dashboardAgentDb, {
|
||||
organizationId: scoped.organizationId,
|
||||
userId,
|
||||
// The chat the panel has on screen, if any: it is being read as this is counted.
|
||||
excludeChatId: searchParams.get("chatId") ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
return json({ ...feed, unreadWork });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) return json({ error: "Project not found" }, { status: 404 });
|
||||
|
||||
@@ -121,17 +179,45 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
userId,
|
||||
});
|
||||
|
||||
// One query for all the listed chats, never one per row.
|
||||
const investigatingChatIds = await listChatIdsWithOpenInvestigations(dashboardAgentDb, {
|
||||
organizationId: project.organizationId,
|
||||
userId,
|
||||
});
|
||||
// One query each for all the listed chats, never one 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,
|
||||
hasOpenInvestigation: investigatingChatIds.has(chat.id),
|
||||
})),
|
||||
chats: chats.map((chat) => {
|
||||
const watches = watchesByChat[chat.id] ?? [];
|
||||
return {
|
||||
...chat,
|
||||
watches,
|
||||
hasUnreadWake: unreadChatIds.has(chat.id),
|
||||
// Work that finished while the chat was closed: the transcript moved on after the
|
||||
// last time its owner looked. A wake is one way that happens, an answer is another.
|
||||
hasUnreadWork:
|
||||
chat.lastMessageAt !== null &&
|
||||
(chat.lastReadAt === null || chat.lastMessageAt > chat.lastReadAt),
|
||||
// `watches` also carries fired and expired rows, so check for active here.
|
||||
hasActiveWatch: watches.some((watch) => watch.status === "active"),
|
||||
hasOpenInvestigation: investigatingChatIds.has(chat.id),
|
||||
};
|
||||
}),
|
||||
unreadWakes,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -383,6 +469,81 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
return json({ resolved });
|
||||
}
|
||||
|
||||
// The configuration card's submit path. The environment comes from the URL and goes
|
||||
// through the same re-authorization a background tick passes, never from the body.
|
||||
if (parsed.data.intent === "watch-create") {
|
||||
// No fallback: a per-condition key would identify the condition rather than this
|
||||
// submit, so a re-watch could replay a stale terminal outcome.
|
||||
const clientRequestId = parsed.data.clientRequestId;
|
||||
if (!clientRequestId) {
|
||||
return json({ error: "That watch isn't valid.", code: "invalid_request" }, { status: 400 });
|
||||
}
|
||||
|
||||
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, so a card submitted from a fresh panel creates a chat.
|
||||
const targetChatId = parsed.data.chatId;
|
||||
if (
|
||||
targetChatId &&
|
||||
!(await chatExists(dashboardAgentDb, {
|
||||
chatId: targetChatId,
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
}))
|
||||
) {
|
||||
return json({ error: "Chat not found", code: "chat_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// The request record is written before the watch and the confirmation after, so a
|
||||
// half-finished submit is repairable and never leaves a watch nobody can see.
|
||||
const result = await submitDashboardAgentWatch({
|
||||
environment,
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
chatId: targetChatId,
|
||||
clientRequestId,
|
||||
draft,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return json(
|
||||
{
|
||||
error: result.error,
|
||||
code: result.code,
|
||||
...(result.existingId ? { existingId: result.existingId } : {}),
|
||||
},
|
||||
{ status: watchErrorStatus(result.code) }
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
chatId: result.chatId,
|
||||
watching: result.watching,
|
||||
watchId: result.watchId,
|
||||
messages: result.messages,
|
||||
});
|
||||
}
|
||||
|
||||
const { intent, chatId } = parsed.data;
|
||||
if (!chatId) return json({ error: "chatId is required" }, { status: 400 });
|
||||
|
||||
@@ -459,9 +620,19 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// The update is owner-scoped, so a chatId the caller doesn't own is a no-op.
|
||||
case "read": {
|
||||
await markChatRead(dashboardAgentDb, {
|
||||
chatId,
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
// Existence check gives a 404 for a chat this caller can't see; the delete itself
|
||||
// is org- and owner-scoped too.
|
||||
// is org- and owner-scoped too, and ends the chat's watches in the same transaction.
|
||||
if (
|
||||
!(await chatExists(dashboardAgentDb, {
|
||||
chatId,
|
||||
@@ -471,12 +642,44 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
) {
|
||||
return json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
await softDeleteChat(dashboardAgentDb, {
|
||||
// The delete and the watch cancellations land in one transaction.
|
||||
const { cancelledWatches } = await deleteChatWithWatches({
|
||||
chatId,
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
return json({ ok: true });
|
||||
return json({ ok: true, cancelledWatches });
|
||||
}
|
||||
|
||||
// Ownership goes through the chat: the watch must belong to the named chat, and
|
||||
// that chat to this user in this org.
|
||||
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,
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
}))
|
||||
) {
|
||||
return json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only an active row is cancelled, so an already-resolved watch keeps its outcome,
|
||||
// this is a no-op and no note is written.
|
||||
const { messages } = await cancelDashboardAgentWatch({
|
||||
watchId,
|
||||
userId,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
return json({ ok: true, messages });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+13
@@ -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,
|
||||
@@ -1147,6 +1150,16 @@ 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 : (
|
||||
<WatchButton
|
||||
spec={runWatchRecommendation(run.friendlyId)}
|
||||
variant="primary"
|
||||
className="self-start"
|
||||
/>
|
||||
)}
|
||||
<RunTimeline run={run} />
|
||||
|
||||
{run.error && (
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import { signUserActorToken } from "@trigger.dev/rbac";
|
||||
import { TriggerClient } from "@trigger.dev/sdk";
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { Counter } from "prom-client";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { metricsRegister } from "~/metrics.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { githubApp } from "./gitHub.server";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
const TASK_ID = "dashboard-agent";
|
||||
|
||||
// The wake poll runs once a minute per visible tab. That trade-off is only defensible while it
|
||||
// stays measurable, so count the requests. singleton: module-scope registration double-registers
|
||||
// under dev HMR.
|
||||
export const dashboardAgentWakeFeedCounter = singleton(
|
||||
"dashboardAgentWakeFeedCounter",
|
||||
() =>
|
||||
new Counter({
|
||||
name: "dashboard_agent_wake_feed_requests_total",
|
||||
help: "Requests to the dashboard agent's wake feed",
|
||||
registers: [metricsRegister],
|
||||
})
|
||||
);
|
||||
|
||||
// Read-only cap on the agent's delegated user-actor token. `read:apiKeys` is
|
||||
// what lets it exchange the token for an env JWT (the gate on the exchange
|
||||
// route); the rest scope the actual reads. No write/admin scopes, so even a
|
||||
// leaked token can't mutate anything.
|
||||
const DASHBOARD_AGENT_UAT_CAP = [
|
||||
export const DASHBOARD_AGENT_UAT_CAP = [
|
||||
"read:apiKeys",
|
||||
"read:runs",
|
||||
"read:deployments",
|
||||
"read:environments",
|
||||
"read:errors",
|
||||
"read:query",
|
||||
// Queue metrics ride on `read:query`, but a queue's own row — paused, depth, limit —
|
||||
// is a `queues` read, and without it the agent can only see the metrics window.
|
||||
"read:queues",
|
||||
];
|
||||
|
||||
// Minted fresh on every turn (the `in` proxy injects it), so the lifetime only
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* From the turn's environment scope and a chat id to an authorized environment. Same order
|
||||
* of authority as the watches route: token environment, chat ownership, re-authorization.
|
||||
*/
|
||||
|
||||
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<AgentAlertContext> {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The credential in a watch alert email's unsubscribe link. HS256 over `SESSION_SECRET` with
|
||||
* a prefix and `kind` claim disjoint from every other token signed with that secret.
|
||||
*/
|
||||
|
||||
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<string> {
|
||||
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<UnsubscribeTokenClaims | undefined> {
|
||||
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 };
|
||||
}
|
||||
|
||||
export function mintDashboardAgentAlertUnsubscribeToken(opts: {
|
||||
channelId: string;
|
||||
alertType: string;
|
||||
}): Promise<string> {
|
||||
return signDashboardAgentAlertUnsubscribeToken(env.SESSION_SECRET, opts);
|
||||
}
|
||||
|
||||
export function verifyUnsubscribeToken(token: string): Promise<UnsubscribeTokenClaims | undefined> {
|
||||
return verifyDashboardAgentAlertUnsubscribeToken(env.SESSION_SECRET, token);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* The seam between a watch firing and the standard alert pipeline: the enqueue, plus the
|
||||
* gate both the fan-out and the agent's subscribe endpoint consult.
|
||||
*/
|
||||
|
||||
import { type Watch } from "@internal/dashboard-agent-db";
|
||||
import {
|
||||
type PrismaClientOrTransaction,
|
||||
type ProjectAlertChannel,
|
||||
type RuntimeEnvironmentType,
|
||||
} from "@trigger.dev/database";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.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, rather than the full row. */
|
||||
export type WatchFiredAlertSource = Pick<
|
||||
Watch,
|
||||
| "id"
|
||||
| "identity"
|
||||
| "spec"
|
||||
| "organizationId"
|
||||
| "projectId"
|
||||
| "environmentId"
|
||||
| "userId"
|
||||
| "firedAt"
|
||||
| "lastResult"
|
||||
| "resolution"
|
||||
| "observedOutcome"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Queue the alert fan-out for a resolved watch. Only `fired` dispatches; an expiry is
|
||||
* narrated in the chat. The job id is the idempotency key: one fan-out per watch.
|
||||
*/
|
||||
export async function enqueueWatchFiredAlert(
|
||||
watch: WatchFiredAlertSource,
|
||||
outcome: "fired" | "expired"
|
||||
): Promise<void> {
|
||||
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 and never re-reads
|
||||
// the source.
|
||||
resolution: watch.resolution ?? "condition_met",
|
||||
observed: watch.observedOutcome ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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, no plan check: billing
|
||||
* gates that separately. `organizationId` stays in the signature for that gate.
|
||||
*/
|
||||
export async function canUseDashboardAgentAlerts(params: {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
organizationId: string;
|
||||
orgFeatureFlags?: Record<string, unknown> | null;
|
||||
}): Promise<DashboardAgentAlertGate> {
|
||||
const hasAgent = await canAccessDashboardAgent({
|
||||
// `isAdmin` is left out on purpose: there is no session here, so the gate reads it off
|
||||
// the user row and a watch an admin could create can still alert.
|
||||
userId: params.userId,
|
||||
// Never an impersonated session: this runs in the background.
|
||||
isImpersonating: false,
|
||||
organizationSlug: params.organizationSlug,
|
||||
orgFeatureFlags: params.orgFeatureFlags,
|
||||
});
|
||||
if (!hasAgent) return { allowed: false, reason: "dashboard_agent_disabled" };
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fired watch in this environment would already reach this user outside the
|
||||
* chat. Advisory only: the watch already exists, so every failure answers `none`.
|
||||
*/
|
||||
export async function resolveWatchEmailAlertsState(params: {
|
||||
userId: string;
|
||||
environment: AuthenticatedEnvironment;
|
||||
}): Promise<"subscribed" | "none" | "unavailable"> {
|
||||
const { userId, environment } = params;
|
||||
try {
|
||||
// Another member's channel mails them, not this user, so only this user's own channel
|
||||
// answers "subscribed".
|
||||
const owner = await resolveWatchAlertOwnership(userId, $replica);
|
||||
const channel = owner
|
||||
? await $replica.projectAlertChannel.findFirst({
|
||||
where: {
|
||||
projectId: environment.project.id,
|
||||
deduplicationKey: owner.deduplicationKey,
|
||||
enabled: true,
|
||||
alertTypes: { has: DASHBOARD_AGENT_WATCH_ALERT_TYPE },
|
||||
environmentTypes: { has: environment.type },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (channel) return "subscribed";
|
||||
|
||||
const gate = await canUseDashboardAgentAlerts({
|
||||
userId,
|
||||
organizationId: environment.organizationId,
|
||||
organizationSlug: environment.organization.slug,
|
||||
orgFeatureFlags: environment.organization.featureFlags as Record<string, unknown> | null,
|
||||
});
|
||||
return gate.allowed ? "none" : "unavailable";
|
||||
} catch (error) {
|
||||
logger.error("Failed to resolve dashboard agent watch alert state", {
|
||||
error,
|
||||
userId,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.project.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
/** The same gate plus an email transport, or the channel would never deliver. */
|
||||
export async function canUseDashboardAgentEmailAlerts(
|
||||
params: Parameters<typeof canUseDashboardAgentAlerts>[0] & { projectId: string }
|
||||
): Promise<DashboardAgentAlertGate> {
|
||||
const base = await canUseDashboardAgentAlerts(params);
|
||||
if (!base.allowed) return base;
|
||||
|
||||
// Mirrors what the alerts email client needs, 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 };
|
||||
}
|
||||
|
||||
// A channel has no owner column, so this key is the only record of whose channel it is.
|
||||
export function watchAlertDeduplicationKey(email: string): string {
|
||||
return `dashboard-agent-watch:${email}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a user id becomes watch-alert ownership. Reading state, subscribing and
|
||||
* unsubscribing all go through here, so they cannot disagree about whose channel is whose.
|
||||
*/
|
||||
async function resolveWatchAlertOwnership(
|
||||
userId: string,
|
||||
db: PrismaClientOrTransaction = prisma
|
||||
): Promise<{ email: string; deduplicationKey: string } | undefined> {
|
||||
const user = await db.user.findFirst({ where: { id: userId }, select: { email: true } });
|
||||
if (!user) return undefined;
|
||||
return { email: user.email, deduplicationKey: watchAlertDeduplicationKey(user.email) };
|
||||
}
|
||||
|
||||
/** How many times a lost race is retried before the subscribe is reported as failed. */
|
||||
const SUBSCRIBE_ATTEMPTS = 3;
|
||||
|
||||
function withoutDuplicates<T>(list: T[], value: T): T[] {
|
||||
return list.includes(value) ? list : [...list, value];
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" && error !== null && (error as { code?: string }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put this environment type on the user's watch-alert channel, creating the channel if there
|
||||
* is none. One channel per (email, project), so subscribing in a second environment must add
|
||||
* to the list rather than replace it — replacing silently stops the first one's mail.
|
||||
*
|
||||
* The update is conditional on the lists the read saw, so two environments subscribing at
|
||||
* once cannot drop each other's addition: the loser sees no updated row and reads again.
|
||||
*/
|
||||
export async function subscribeChannelToWatchAlerts(params: {
|
||||
userId: string;
|
||||
email: string;
|
||||
deduplicationKey: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
project: { id: string; externalRef: string };
|
||||
}): Promise<Pick<ProjectAlertChannel, "id" | "type" | "enabled" | "environmentTypes">> {
|
||||
const { userId, email, deduplicationKey, environmentType, project } = params;
|
||||
const name = `Watch alerts for ${email}`;
|
||||
|
||||
for (let attempt = 0; attempt < SUBSCRIBE_ATTEMPTS; attempt++) {
|
||||
const existing = await prisma.projectAlertChannel.findFirst({
|
||||
where: { projectId: project.id, deduplicationKey },
|
||||
select: { id: true, alertTypes: true, environmentTypes: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
// The service also checks this user's membership of the project.
|
||||
return await new CreateAlertChannelService().call(project.externalRef, userId, {
|
||||
name,
|
||||
alertTypes: [DASHBOARD_AGENT_WATCH_ALERT_TYPE],
|
||||
environmentTypes: [environmentType],
|
||||
deduplicationKey,
|
||||
channel: { type: "EMAIL", email },
|
||||
});
|
||||
} catch (error) {
|
||||
// Another environment created the channel first; the next attempt adds onto it.
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const environmentTypes = withoutDuplicates(existing.environmentTypes, environmentType);
|
||||
const alertTypes = withoutDuplicates(existing.alertTypes, DASHBOARD_AGENT_WATCH_ALERT_TYPE);
|
||||
|
||||
const { count } = await prisma.projectAlertChannel.updateMany({
|
||||
// Compare-and-swap on the lists the read returned.
|
||||
where: {
|
||||
id: existing.id,
|
||||
projectId: project.id,
|
||||
deduplicationKey,
|
||||
environmentTypes: { equals: existing.environmentTypes },
|
||||
alertTypes: { equals: existing.alertTypes },
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
alertTypes,
|
||||
environmentTypes,
|
||||
type: "EMAIL",
|
||||
properties: { email },
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
return { id: existing.id, type: "EMAIL", enabled: true, environmentTypes };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not subscribe to watch alerts: the channel kept changing underneath");
|
||||
}
|
||||
|
||||
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
|
||||
* address is never taken from the request, and the dedup key is stable per (email, project).
|
||||
*/
|
||||
export async function subscribeUserToWatchAlerts(params: {
|
||||
userId: string;
|
||||
environment: {
|
||||
type: string;
|
||||
organizationId: string;
|
||||
organization: { slug: string };
|
||||
project: { id: string; externalRef: string };
|
||||
};
|
||||
}): Promise<SubscribeToWatchAlertsResult> {
|
||||
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 owner = await resolveWatchAlertOwnership(userId);
|
||||
if (!owner) return { ok: false, reason: "user_not_found" };
|
||||
|
||||
await subscribeChannelToWatchAlerts({
|
||||
userId,
|
||||
email: owner.email,
|
||||
deduplicationKey: owner.deduplicationKey,
|
||||
environmentType: environment.type as RuntimeEnvironmentType,
|
||||
project: environment.project,
|
||||
});
|
||||
|
||||
return { ok: true, email: owner.email };
|
||||
}
|
||||
|
||||
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, disabling one left with no alert types. The
|
||||
* write is conditional on the list the read saw, so a concurrent edit fails this attempt.
|
||||
*
|
||||
* A project is shared by every member, so a request-driven caller must pass
|
||||
* `organizationId` and `ownerUserId` too.
|
||||
*/
|
||||
export async function unsubscribeChannelFromWatchAlerts(
|
||||
channelId: string,
|
||||
options: { projectId?: string; organizationId?: string; ownerUserId?: string } = {},
|
||||
db: PrismaClientOrTransaction = prisma
|
||||
): Promise<UnsubscribeResult> {
|
||||
let ownerKey: string | undefined;
|
||||
if (options.ownerUserId) {
|
||||
const owner = await resolveWatchAlertOwnership(options.ownerUserId, db);
|
||||
if (!owner) return { ok: false, reason: "not_found" };
|
||||
ownerKey = owner.deduplicationKey;
|
||||
}
|
||||
|
||||
const scope = {
|
||||
id: channelId,
|
||||
...(options.projectId ? { projectId: options.projectId } : {}),
|
||||
...(options.organizationId ? { project: { organizationId: options.organizationId } } : {}),
|
||||
...(ownerKey ? { deduplicationKey: ownerKey } : {}),
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < UNSUBSCRIBE_ATTEMPTS; attempt++) {
|
||||
const channel = await db.projectAlertChannel.findFirst({
|
||||
where: scope,
|
||||
select: { name: true, alertTypes: true, projectId: true, deduplicationKey: true },
|
||||
});
|
||||
// A channel this alert type was never on is out of scope: stripping nothing off it
|
||||
// would still report success, and an empty list would disable it.
|
||||
if (!channel || !channel.alertTypes.includes(DASHBOARD_AGENT_WATCH_ALERT_TYPE)) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
const remaining = channel.alertTypes.filter(
|
||||
(type) => type !== DASHBOARD_AGENT_WATCH_ALERT_TYPE
|
||||
);
|
||||
|
||||
const { count } = await db.projectAlertChannel.updateMany({
|
||||
// Compare-and-swap on the row the scoped read returned. `updateMany` takes no relation
|
||||
// filter, so the org scope is carried by the read's `projectId`.
|
||||
where: {
|
||||
id: channelId,
|
||||
projectId: channel.projectId,
|
||||
deduplicationKey: channel.deduplicationKey,
|
||||
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" };
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* The batch check: every due watch of one (environment, cadence) group in one pass. Each row is
|
||||
* the authority on its own snapshot, and each initiating user is re-authorized before any read.
|
||||
*/
|
||||
|
||||
import {
|
||||
cancelWatch,
|
||||
claimWatchBatchTick,
|
||||
listActiveWatchesForBatch,
|
||||
listWatchesAwaitingDeliveryForBatch,
|
||||
recordWatchAttempt,
|
||||
recordWatchCheck,
|
||||
stopWatchBatch,
|
||||
WATCH_DELIVERY_CLAIM_STALE_MS,
|
||||
type Watch,
|
||||
} from "@internal/dashboard-agent-db";
|
||||
import type { WatchBatchCheckEntry, WatchBatchCheckResponse } from "@internal/dashboard-agent";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import {
|
||||
checkWatch,
|
||||
previousCheckFacts,
|
||||
type WatchCheckDeps,
|
||||
} from "~/services/dashboardAgentWatchChecks";
|
||||
import { watchCheckDeps } from "~/services/dashboardAgentWatchChecks.server";
|
||||
import {
|
||||
authorizeWatchEnvironment,
|
||||
type WatchAuthorization,
|
||||
} from "~/services/dashboardAgentWatches.server";
|
||||
import {
|
||||
mintDashboardAgentWatchToken,
|
||||
WATCH_TOKEN_GRACE_MS,
|
||||
} from "~/services/dashboardAgentWatchToken.server";
|
||||
|
||||
/**
|
||||
* How early a watch may be checked and still count as due, so a tick landing seconds
|
||||
* early doesn't defer it a whole cadence. Capped at half a cadence.
|
||||
*/
|
||||
function dueSlackMs(cadenceMinutes: number): number {
|
||||
return Math.min(30_000, (cadenceMinutes * 60_000) / 2);
|
||||
}
|
||||
|
||||
/** Small on purpose: it stops one slow condition serializing the group, not to fan out. */
|
||||
const EVALUATION_CONCURRENCY = 8;
|
||||
|
||||
export type WatchBatchCheckDeps = {
|
||||
now?: () => Date;
|
||||
/** The group's active watches. */
|
||||
listActive?: (params: { environmentId: string; cadenceMinutes: number }) => Promise<Watch[]>;
|
||||
/** The group's resolved watches whose wake is still owed. */
|
||||
listOwed?: (params: {
|
||||
environmentId: string;
|
||||
cadenceMinutes: number;
|
||||
claimStaleBefore: Date;
|
||||
}) => Promise<Watch[]>;
|
||||
/** Re-authorization of one watch's initiating user. */
|
||||
authorize?: (watch: Watch) => Promise<WatchAuthorization>;
|
||||
/** The environment readers the conditions run against. */
|
||||
checkDeps?: (environment: AuthenticatedEnvironment, now: Date) => WatchCheckDeps;
|
||||
/** The per-watch token the fired / investigate callbacks are made with. */
|
||||
mintToken?: (watch: Watch) => Promise<string>;
|
||||
concurrency?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run one batch tick's checks. The claim decides whether this run owns the tick and keeps the
|
||||
* schedule single-file; the guarded transition and fenced delivery claim stop a double fire.
|
||||
*/
|
||||
export async function runWatchBatchCheck(
|
||||
params: { environmentId: string; cadenceMinutes: number; epoch: number; tick: number },
|
||||
deps: WatchBatchCheckDeps = {}
|
||||
): Promise<WatchBatchCheckResponse> {
|
||||
const now = deps.now?.() ?? new Date();
|
||||
const listActive =
|
||||
deps.listActive ?? ((args) => listActiveWatchesForBatch(dashboardAgentDb, args));
|
||||
const listOwed =
|
||||
deps.listOwed ?? ((args) => listWatchesAwaitingDeliveryForBatch(dashboardAgentDb, args));
|
||||
const mintToken =
|
||||
deps.mintToken ??
|
||||
((watch: Watch) =>
|
||||
mintDashboardAgentWatchToken({ watchId: watch.id, expiresAt: watch.expiresAt }));
|
||||
|
||||
const claimed = await claimWatchBatchTick(dashboardAgentDb, {
|
||||
environmentId: params.environmentId,
|
||||
cadenceMinutes: params.cadenceMinutes,
|
||||
epoch: params.epoch,
|
||||
// The tick a run carries is the generation it owns.
|
||||
generation: params.tick,
|
||||
});
|
||||
if (!claimed) {
|
||||
logger.debug("Dashboard agent watch batch: the tick is stale", params);
|
||||
return { stale: true };
|
||||
}
|
||||
|
||||
const active = await listActive({
|
||||
environmentId: params.environmentId,
|
||||
cadenceMinutes: params.cadenceMinutes,
|
||||
});
|
||||
|
||||
const due = active.filter((watch) => isDue(watch, params.cadenceMinutes, now));
|
||||
const evaluated = await evaluateGroup(due, params, { ...deps, now: () => now }, mintToken);
|
||||
|
||||
// Wakes this group still owes. Read after the evaluation, so a wake this tick resolved
|
||||
// and failed to deliver is already in it.
|
||||
const owed = await listOwed({
|
||||
environmentId: params.environmentId,
|
||||
cadenceMinutes: params.cadenceMinutes,
|
||||
claimStaleBefore: new Date(now.getTime() - WATCH_DELIVERY_CLAIM_STALE_MS),
|
||||
});
|
||||
|
||||
const deliveries = await Promise.all(
|
||||
owed.map(async (watch) => ({
|
||||
watchId: watch.id,
|
||||
token: await mintToken(watch),
|
||||
// A delivery decides nothing, so it claims no generation.
|
||||
tick: 0,
|
||||
deliverOnly: true as const,
|
||||
}))
|
||||
);
|
||||
|
||||
// The chain only stops with nothing to poll and nothing owed: stopping while a wake is
|
||||
// owed strands it. Fenced on the epoch, so it can only end this run's own chain.
|
||||
const continues = active.length > 0 || owed.length > 0;
|
||||
if (!continues) {
|
||||
await stopWatchBatch(dashboardAgentDb, {
|
||||
environmentId: params.environmentId,
|
||||
cadenceMinutes: params.cadenceMinutes,
|
||||
epoch: params.epoch,
|
||||
});
|
||||
}
|
||||
|
||||
return { watches: [...evaluated, ...deliveries], continues };
|
||||
}
|
||||
|
||||
/**
|
||||
* A watch whose window closes before the next tick is due now, so its final evaluation is
|
||||
* never missed. A watch past the token grace is never due: the expiry sweep owns it.
|
||||
*/
|
||||
export function isDue(watch: Watch, cadenceMinutes: number, now: Date): boolean {
|
||||
const nowMs = now.getTime();
|
||||
const cadenceMs = cadenceMinutes * 60_000;
|
||||
|
||||
if (nowMs > watch.expiresAt.getTime() + WATCH_TOKEN_GRACE_MS) return false;
|
||||
if (watch.expiresAt.getTime() <= nowMs + cadenceMs) return true;
|
||||
|
||||
const lastChecked = watch.lastCheckedAt?.getTime();
|
||||
return lastChecked === undefined || lastChecked <= nowMs - cadenceMs + dueSlackMs(cadenceMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the due watches against one set of readers. Authorization is cached per (user, org,
|
||||
* project); each watch runs in its own try, so a failure is that watch's answer alone.
|
||||
*/
|
||||
async function evaluateGroup(
|
||||
due: Watch[],
|
||||
params: { environmentId: string; cadenceMinutes: number },
|
||||
deps: WatchBatchCheckDeps,
|
||||
mintToken: (watch: Watch) => Promise<string>
|
||||
): Promise<WatchBatchCheckEntry[]> {
|
||||
if (due.length === 0) return [];
|
||||
|
||||
const now = deps.now?.() ?? new Date();
|
||||
const authorize = deps.authorize ?? defaultAuthorize;
|
||||
const buildCheckDeps = deps.checkDeps ?? watchCheckDeps;
|
||||
|
||||
const authorizations = new Map<string, Promise<WatchAuthorization>>();
|
||||
const authorizeOnce = (watch: Watch) => {
|
||||
const key = `${watch.userId}:${watch.organizationId}:${watch.projectId}`;
|
||||
const cached = authorizations.get(key);
|
||||
if (cached) return cached;
|
||||
const pending = authorize(watch);
|
||||
authorizations.set(key, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
// Built from the first authorization that passes, then shared: every row in the group
|
||||
// names the same environment.
|
||||
let readers: WatchCheckDeps | undefined;
|
||||
|
||||
const evaluateOne = async (
|
||||
watch: Watch,
|
||||
base: { watchId: string; token: string; tick: number }
|
||||
): Promise<WatchBatchCheckEntry> => {
|
||||
const authorization = await authorizeOnce(watch);
|
||||
if (!authorization.ok) {
|
||||
// Cancel before anything is read: a watch must not outlive its creator's access.
|
||||
await cancelWatch(dashboardAgentDb, { id: watch.id, reason: "access_revoked" });
|
||||
return { ...base, code: "access_revoked", error: "Access to this environment was revoked" };
|
||||
}
|
||||
|
||||
readers ??= shareReads(buildCheckDeps(authorization.environment, now));
|
||||
|
||||
const since = watch.spec.since ? new Date(watch.spec.since) : watch.createdAt;
|
||||
const final = watch.expiresAt.getTime() <= now.getTime();
|
||||
const outcome = await checkWatch(
|
||||
watch.spec,
|
||||
readers,
|
||||
// A check that couldn't read anything freezes a streak instead of resetting it.
|
||||
{ now, since, previous: previousCheckFacts(watch.lastResult) },
|
||||
(error) =>
|
||||
logger.error("Dashboard agent watch batch: a check failed", {
|
||||
error,
|
||||
watchId: watch.id,
|
||||
environmentId: params.environmentId,
|
||||
})
|
||||
);
|
||||
|
||||
// Only a real evaluation is recorded, final or not: `unavailable` means nothing was read,
|
||||
// so writing it would move `lastCheckedAt` and overwrite the facts a streak lives in.
|
||||
// Guarded on `active`, and never touches `tickCount`.
|
||||
if (outcome.result !== "unavailable") {
|
||||
await recordWatchCheck(dashboardAgentDb, {
|
||||
id: watch.id,
|
||||
lastResult: {
|
||||
result: outcome.result,
|
||||
facts: outcome.facts,
|
||||
observed: outcome.observed,
|
||||
final,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Looked at, not checked: this rotates the watch out of its group's head without
|
||||
// touching its dueness or the facts its streak lives in.
|
||||
await recordWatchAttempt(dashboardAgentDb, { id: watch.id });
|
||||
}
|
||||
|
||||
return { ...base, result: outcome.result, facts: outcome.facts, observed: outcome.observed };
|
||||
};
|
||||
|
||||
return mapWithConcurrency(due, deps.concurrency ?? EVALUATION_CONCURRENCY, async (watch) => {
|
||||
// Minted outside the try, because the catch below needs a token it can't fail to have.
|
||||
const base = { watchId: watch.id, token: await mintToken(watch), tick: watch.tickCount + 1 };
|
||||
try {
|
||||
return await evaluateOne(watch, base);
|
||||
} catch (error) {
|
||||
logger.error("Dashboard agent watch batch: a watch couldn't be evaluated", {
|
||||
watchId: watch.id,
|
||||
environmentId: params.environmentId,
|
||||
error,
|
||||
});
|
||||
// `unavailable` is never read as true or false: the watch keeps its state. Still a
|
||||
// look, so the fairness key moves even when nothing else does.
|
||||
await recordWatchAttempt(dashboardAgentDb, { id: watch.id }).catch(() => {});
|
||||
return { ...base, result: "unavailable" as const, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a batch's readers so each distinct read happens once. `now` is fixed for the batch, so
|
||||
* a reader's answer is a pure function of its arguments. Failed reads are cached too.
|
||||
*
|
||||
* Exported for the expiry sweep, which finalizes the same rows against the same readers.
|
||||
*/
|
||||
export function shareReads(readers: WatchCheckDeps): WatchCheckDeps {
|
||||
const cache = new Map<string, Promise<unknown>>();
|
||||
const once = <A extends unknown[], R>(name: string, read: (...args: A) => Promise<R>) => {
|
||||
return (...args: A): Promise<R> => {
|
||||
const key = `${name}:${JSON.stringify(args)}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached as Promise<R>;
|
||||
const pending = read(...args);
|
||||
cache.set(key, pending);
|
||||
return pending;
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
readRun: once("readRun", readers.readRun),
|
||||
queueExists: once("queueExists", readers.queueExists),
|
||||
readQueueDepth: once("readQueueDepth", readers.readQueueDepth),
|
||||
readQueueOldestAge: once("readQueueOldestAge", readers.readQueueOldestAge),
|
||||
readErrorRecurrence: once("readErrorRecurrence", readers.readErrorRecurrence),
|
||||
readHealth: once("readHealth", readers.readHealth),
|
||||
};
|
||||
}
|
||||
|
||||
/** `mapper` over `items`, at most `limit` in flight. Order is preserved. */
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
mapper: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
|
||||
while (next < items.length) {
|
||||
const index = next++;
|
||||
results[index] = await mapper(items[index]!);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function defaultAuthorize(watch: Watch): Promise<WatchAuthorization> {
|
||||
return authorizeWatchEnvironment({
|
||||
userId: watch.userId,
|
||||
organizationId: watch.organizationId,
|
||||
projectId: watch.projectId,
|
||||
environmentId: watch.environmentId,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user