diff --git a/.changeset/report-json-and-period-units.md b/.changeset/report-json-and-period-units.md new file mode 100644 index 00000000000..5567d2a5b9b --- /dev/null +++ b/.changeset/report-json-and-period-units.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Reports can be fetched as structured data with the `json` format. The shortest report period is now one minute (`30m`, `1h`, `7d`). diff --git a/.gitignore b/.gitignore index f540927e32b..b11dded2b02 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ ailogger-output.log # observability-map CLI output artifact, not committed observability-map.json + +.claude/worktrees/ diff --git a/.server-changes/dashboard-agent.md b/.server-changes/dashboard-agent.md new file mode 100644 index 00000000000..504bb70c4cf --- /dev/null +++ b/.server-changes/dashboard-agent.md @@ -0,0 +1,14 @@ +--- +area: webapp +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. It takes over from Ask AI everywhere that used to appear; on the Free plan you get 20 messages. + +**Investigate** on a failed run, an error, a backed-up queue or a run that hasn't started gets you a worked-through answer โ€” what happened, why, and how to fix it, with every claim linked to the runs, errors and deploys behind it. + +The health report reads the same everywhere โ€” dashboard, terminal, editor. A very long chat keeps working: the agent summarises the earlier part and carries on. The agent's replies no longer show images. + +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. + +Separately: a queue's wait times, peak depth, throughput and throttling can now be read from the API, and the Docs button has been removed from page headers. diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index 595ab180e15..f825411d640 100644 --- a/apps/webapp/.gitignore +++ b/apps/webapp/.gitignore @@ -7,6 +7,9 @@ node_modules /cypress/screenshots /cypress/videos +# Output of `pnpm run agent-ui:screenshots` +/screenshots + /app/styles/tailwind.css # Ensure the .env symlink is not removed by accident @@ -20,4 +23,4 @@ storybook-static /prisma/seed.js /prisma/populate.js -.memory-snapshots \ No newline at end of file +.memory-snapshots diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index d61ea0055fa..389d5e9e569 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -1,3 +1,9 @@ +/** + * @deprecated Superseded by the dashboard agent (`components/dashboard-agent`). Nothing mounts + * this any more โ€” every Ask AI entry point now opens Ask Trigger. Kept until the agent has + * shipped, then removed along with `@kapaai/react-sdk` and `KAPA_AI_WEBSITE_ID`. + */ + import { ArrowPathIcon, ArrowUpIcon, @@ -81,6 +87,8 @@ function useAskAIState() { * it around the popover, not inside, so the dialog and shortcut survive the popover closing. * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no * Kapa website id, or SSR). + * + * @deprecated See the note at the top of this file. */ export function AskAIRoot({ children, @@ -137,6 +145,7 @@ function AskAIRootProvider({ ); } +/** @deprecated See the note at the top of this file. */ export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { const { isManagedCloud } = useFeatures(); const websiteId = useKapaWebsiteId(); diff --git a/apps/webapp/app/components/dashboard-agent/message-limits.test.ts b/apps/webapp/app/components/dashboard-agent/message-limits.test.ts new file mode 100644 index 00000000000..acc51e14302 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/message-limits.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + checkMessageParts, + declaredBodyBytes, + exceedsMessageBodyBytes, + MAX_MESSAGE_BODY_BYTES, + MAX_MESSAGE_CHARS, + MAX_MESSAGE_PARTS, +} from "./message-limits"; + +describe("message limits", () => { + it("lets a long real question through", () => { + const text = "why did this fail?\n".repeat(50); + + expect(exceedsMessageBodyBytes(Buffer.byteLength(text, "utf8"))).toBe(false); + expect(checkMessageParts([{ type: "text", text }])).toBeNull(); + }); + + it("refuses a pasted dump by bytes", () => { + expect(exceedsMessageBodyBytes(MAX_MESSAGE_BODY_BYTES)).toBe(false); + expect(exceedsMessageBodyBytes(MAX_MESSAGE_BODY_BYTES + 1)).toBe(true); + }); + + it("counts multi-byte characters as bytes, not characters", () => { + // Under the char cap, over the byte cap: 4 bytes each. + const emoji = "๐Ÿ™‚".repeat(MAX_MESSAGE_BODY_BYTES / 4 + 1); + + expect(emoji.length).toBeLessThan(MAX_MESSAGE_BODY_BYTES); + expect(exceedsMessageBodyBytes(Buffer.byteLength(emoji, "utf8"))).toBe(true); + }); + + it("refuses a dump split across parts", () => { + const parts = Array.from({ length: 4 }, () => ({ + type: "text", + text: "x".repeat(MAX_MESSAGE_CHARS / 2), + })); + + expect(checkMessageParts(parts)).toBe("too_long"); + }); + + it("refuses too many parts", () => { + const parts = Array.from({ length: MAX_MESSAGE_PARTS + 1 }, () => ({ + type: "text", + text: "x", + })); + + expect(checkMessageParts(parts)).toBe("too_many_parts"); + expect(checkMessageParts(parts.slice(0, MAX_MESSAGE_PARTS))).toBeNull(); + }); + + it("leaves a shape that isn't a parts array to the schema", () => { + expect(checkMessageParts(undefined)).toBeNull(); + expect(checkMessageParts("nope")).toBeNull(); + }); + + it("reads the declared size, or nothing when it isn't declared", () => { + expect(declaredBodyBytes(new Headers({ "content-length": "1234" }))).toBe(1234); + expect(declaredBodyBytes(new Headers())).toBeNull(); + expect(declaredBodyBytes(new Headers({ "content-length": "nope" }))).toBeNull(); + // An undeclared size can't be refused here; the body's own length is. + expect(exceedsMessageBodyBytes(null)).toBe(false); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/message-limits.ts b/apps/webapp/app/components/dashboard-agent/message-limits.ts new file mode 100644 index 00000000000..fe395993884 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/message-limits.ts @@ -0,0 +1,51 @@ +/** + * Caps on one message to the agent, shared by the composer and the two server paths a message + * can arrive through. Generous for a real question with a pasted stack trace, stingy for a dump: + * an unbounded paste is a large model bill and a permanently fat transcript. + */ + +/** ~2 pages of text, or a long stack trace. */ +export const MAX_MESSAGE_CHARS = 8_000; + +/** The counter only shows near the limit, so a normal message never sees it. */ +export const MESSAGE_CHARS_WARN_AT = Math.floor(MAX_MESSAGE_CHARS * 0.9); + +/** A composed message is a handful of parts; dozens means something is wrong. */ +export const MAX_MESSAGE_PARTS = 20; + +/** + * The whole request body, in bytes: headroom for {@link MAX_MESSAGE_CHARS} of any script plus + * the per-turn metadata, and nothing like a pasted file. + */ +export const MAX_MESSAGE_BODY_BYTES = 64 * 1024; + +export const MESSAGE_TOO_LARGE_CODE = "message_too_large"; + +export const MESSAGE_TOO_LARGE_ERROR = "That message is too long. Shorten it and send again."; + +export type MessagePartsProblem = "too_many_parts" | "too_long"; + +/** Counts the parts and their text. Anything that isn't a parts array is left to the schema. */ +export function checkMessageParts(parts: unknown): MessagePartsProblem | null { + if (!Array.isArray(parts)) return null; + if (parts.length > MAX_MESSAGE_PARTS) return "too_many_parts"; + + let chars = 0; + for (const part of parts) { + const text = (part as { text?: unknown } | null)?.text; + if (typeof text === "string") chars += text.length; + } + return chars > MAX_MESSAGE_CHARS ? "too_long" : null; +} + +/** The declared body size, or null when the client didn't declare one. */ +export function declaredBodyBytes(headers: Headers): number | null { + const raw = headers.get("content-length"); + if (!raw) return null; + const bytes = Number.parseInt(raw, 10); + return Number.isFinite(bytes) ? bytes : null; +} + +export function exceedsMessageBodyBytes(bytes: number | null | undefined): boolean { + return typeof bytes === "number" && bytes > MAX_MESSAGE_BODY_BYTES; +} diff --git a/apps/webapp/app/components/dashboard-agent/resolve-uris.test.ts b/apps/webapp/app/components/dashboard-agent/resolve-uris.test.ts new file mode 100644 index 00000000000..c7b93a0b522 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/resolve-uris.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { MAX_URIS_PER_RESOLVE_REQUEST, planUriBatches } from "./resolve-uris"; + +const uri = (index: number) => `trigger://runs/run_${index}`; + +describe("planUriBatches", () => { + it("resolves a card's twenty citations in one request", () => { + const batches = planUriBatches(Array.from({ length: 20 }, (_, index) => uri(index))); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(20); + }); + + it("asks about each URI once", () => { + const batches = planUriBatches([uri(1), uri(1), uri(2)]); + + expect(batches).toEqual([[uri(1), uri(2)]]); + }); + + it("caps a request and carries the rest over", () => { + const count = MAX_URIS_PER_RESOLVE_REQUEST + 3; + const batches = planUriBatches(Array.from({ length: count }, (_, index) => uri(index))); + + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(MAX_URIS_PER_RESOLVE_REQUEST); + expect(batches[1]).toHaveLength(3); + }); + + it("has nothing to send for nothing", () => { + expect(planUriBatches([])).toEqual([]); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/resolve-uris.ts b/apps/webapp/app/components/dashboard-agent/resolve-uris.ts new file mode 100644 index 00000000000..030510701fe --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/resolve-uris.ts @@ -0,0 +1,25 @@ +/** + * Batching for `trigger://` resolution. An investigation card cites ten to twenty targets, and + * each request re-authorises and re-resolves the environment โ€” so they go in one request. + */ + +/** One environment lookup and one repo lookup serve a whole batch. */ +export const MAX_URIS_PER_RESOLVE_REQUEST = 25; + +/** A transient failure is worth retrying; a third one isn't. */ +export const MAX_RESOLVE_ATTEMPTS = 3; + +export const RESOLVE_RETRY_DELAY_MS = 1_000; + +/** Deduplicates, then splits into requests no bigger than the cap. */ +export function planUriBatches( + uris: readonly string[], + cap: number = MAX_URIS_PER_RESOLVE_REQUEST +): string[][] { + const unique = [...new Set(uris)]; + const batches: string[][] = []; + for (let index = 0; index < unique.length; index += cap) { + batches.push(unique.slice(index, index + cap)); + } + return batches; +} diff --git a/apps/webapp/app/components/metrics/MiniLineChart.tsx b/apps/webapp/app/components/metrics/MiniLineChart.tsx index c36e91d0b90..f758ad3370d 100644 --- a/apps/webapp/app/components/metrics/MiniLineChart.tsx +++ b/apps/webapp/app/components/metrics/MiniLineChart.tsx @@ -42,6 +42,8 @@ export type MiniLineChartProps = { * throttled magnitude carried by the tooltip. */ throttled?: number[]; + /** Tooltip wording for the overlay buckets. Null omits the overlay line. */ + overlayLabel?: string | null; /** Epoch ms of the first bucket's start. When omitted, the last bucket is anchored to now. */ bucketStartMs?: number; /** Width of each bucket in ms. Defaults to one hour. */ @@ -76,6 +78,7 @@ export type MiniLineChartProps = { export function MiniLineChart({ data, throttled, + overlayLabel = "throttled", bucketStartMs, bucketIntervalMs, color = "var(--color-tasks)", @@ -128,7 +131,7 @@ export function MiniLineChart({ } + content={} allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 1000 }} animationDuration={0} @@ -195,7 +198,8 @@ function MiniLineChartTooltip({ active, payload, unitLabel, -}: TooltipProps & { unitLabel: UnitLabel }) { + overlayLabel = "throttled", +}: TooltipProps & { unitLabel: UnitLabel; overlayLabel?: string | null }) { if (!active || !payload || payload.length === 0) return null; const entry = payload[0].payload as MiniLineChartDatum; const date = entry.date instanceof Date ? entry.date : new Date(entry.date); @@ -211,9 +215,9 @@ function MiniLineChartTooltip({ {entry.count === 1 ? unitLabel.singular : unitLabel.plural} - {throttled > 0 && ( + {throttled > 0 && overlayLabel !== null && (
- {throttled.toLocaleString()} throttled + {throttled.toLocaleString()} {overlayLabel}
)} diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 9a06c933d97..189a23da93f 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -242,8 +242,16 @@ export function SideMenuItem({ /** Button styled to match {@link SideMenuItem}, for entries that open a dialog rather than navigate. */ export const SideMenuItemButton = forwardRef< HTMLButtonElement, - { icon: RenderIcon; name: string; trailing?: ReactNode } & ButtonHTMLAttributes ->(function SideMenuItemButton({ icon, name, trailing, className, type, ...props }, ref) { + { + icon: RenderIcon; + name: string; + trailing?: ReactNode; + iconClassName?: string; + } & ButtonHTMLAttributes +>(function SideMenuItemButton( + { icon, name, trailing, className, iconClassName, type, ...props }, + ref +) { return (