Skip to content

Commit 4569657

Browse files
authored
feat(webapp): dashboard agent — chat, reports, investigate (#4418)
## What & why This is the system behind the Dashboard Agent — an assistant that answers questions about a project's runs, errors, queues, deploys and health, and can investigate failures end to end. The agent runs as a chat.agent task in its own Trigger project. It has no access to the main database or ClickHouse; all platform data is read through the public API using a delegated, read-only user token. Everything here is behind `canAccessDashboardAgent` and inert with the flag off. The UI that mounts the panel lands in #4529. ## Stack `#4418` (this, base) ← `#4529` UI ← `#4525` Watch ← `#4516` storybook gallery. The scenario/contract reference for the whole stack is `internal-packages/dashboard-agent/GUIDEBOOK.md` (it lands on the Watch branch): it states, per feature, what makes each thing happen and where that is decided. ## What's inside **Agent runtime and tools** — `internal-packages/dashboard-agent`: prompt, tool set (API reads, TRQL query, docs, navigation, evidence/investigations, repo source), conversation compaction, a prompt-prefix token budget pinned by snapshot test, and sampled LLM-judged turn evals. The package cannot import webapp server code, which is what makes the "no DB access" claim structural rather than a convention. **Contracts** — `internal-packages/dashboard-agent-contracts`: `trigger://` URIs, intents, and the block envelope every rendered card travels in. **Conversation store** — `internal-packages/dashboard-agent-db`: drizzle over postgres-js in its own `trigger_dashboard_agent` Postgres schema, plus one additive migration. **Auth boundary** — the user-actor token gains an optional environment claim; one guard (`userActorEnvironment.server.ts`) enforces it so routes don't each re-derive the rule. Token minting, cap ceiling, and the RBAC fallback path for self-hosted. **Transport** — webapp resource routes that mint the token and proxy each turn, and SDK-side mid-turn reconnect. **Public API the agent reads through** — orgs, projects, environments, runs, queue metrics, workers, a run's commit metadata, repo snapshot, reports, and `POST /api/v1/query`. **Reports** — the health report's layout is declared once and shared by the card, the markdown surface and the JSON/MCP surface, so the same report reads the same in the dashboard, the terminal and an editor. **Block renderers** — the report and investigation cards the flows above already emit (`app/components/dashboard-agent/`). The panel that hosts them, and the rest of the chat UI, is #4529. **Query safety and CSP** — see below. ## Key decisions - **The agent is a separate Trigger project, not webapp code.** It reads platform data over the public API with a delegated user-actor token whose `cap` ceilings it to read scopes. No Prisma, no ClickHouse, no webapp imports. - **The PAT-only auth helper now refuses user-actor tokens.** This is an intentional behavioral change: its callers consume only a bare userId and do not enforce delegated-token capabilities. Actor-aware routes continue through the scoped route builders instead. - **RBAC fallback builds a delegated token's ability from its own cap**, never the blanket ability a PAT gets (read-only when the token declares none). Without this, the agent's read-only cap would buy a write JWT on self-hosted. - **Org creation checks RBAC only for user-actor tokens, and only after the env gate**, so an install with `ORG_CREATION_API_ENABLED` off returns 404 rather than 403, and an ordinary PAT never consults an ability the route has no org to scope. Both orderings are pinned by test. - **The query path is read-only in depth.** TRQL rejects write statements at the grammar level (they don't parse, rather than being filtered), ClickHouse runs with `readonly=1`, and the org/project/env filters are injected server-side from the credential — the request body cannot widen scope. An unparseable query denies instead of falling through to the permissive resource. - **Document-wide img-src CSP.** Remote images are an outbound-request/exfiltration surface, so the policy permits only own-origin/data/blob, the required SSO avatar hosts, and the favicon endpoint. Operators can add exact origins through CSP_IMG_SRC_ALLOWLIST; wildcard hosts and bare schemes are intentionally not allowed. - **The chat transport reconnects on a mid-turn EOF** (`@trigger.dev/sdk`). A body that ends without a turn-complete is terminal only when the server says `X-Session-Settled: true`; otherwise the transport resubscribes from `lastEventId` with bounded backoff, and any record re-earns the budget. Previously a closed long-poll window or a proxy restart left the reply stuck as if still generating. - **Conversations live in their own datastore**, schema-scoped and foreign-key-free (it references `organizationId`/`userId` by id, because in cloud it is a different database). It is a display read-model for the History tab and transport resume; `chat.agent`'s object-store snapshot remains the model's source of truth. - **Deterministic first.** Reports and health checks contain no LLM — they are computed from the same data the dashboard shows, and the model only narrates and links them. That is what makes a number in an answer auditable. ## Testing - 63 new test files, run with `pnpm run test --filter webapp` and per-package vitest. Heaviest coverage on the auth boundary (`userActorPatOnlyBoundary`, `userActorTokenClaimsAndScopes`, `contextlessPatRoutes`, `rbacFallbackBranch`), TRQL read-only, the report layout, and the SDK reconnect. - The agent package has a separate eval lane (`pnpm run test:evals`, `vitest.eval.config.ts`) that hits the real model, so it never runs in `pnpm test`. - Live-tested against a local stack scenario by scenario; the GUIDEBOOK lists the condition each behaviour is expected under, which is what those runs were checked against. ## Changelog `.server-changes/dashboard-agent.md`, plus changesets for `@trigger.dev/core` (report schemas), `@trigger.dev/sdk` (chat reconnect) and the CLI's `mint-token` help text.
1 parent 02de2e6 commit 4569657

228 files changed

Lines changed: 27293 additions & 3217 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
"trigger.dev": patch
5+
---
6+
7+
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating. Reports can be fetched as structured data with the `json` format, and the shortest report period is now one minute (`30m`, `1h`, `7d`). The `mint-token` command's help is clearer too: a token minted without `--cap` is read-only, and `--ttl` shows the correct maximum lifetime of 7 days.

.claude/skills/errors-api-e2e/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ PASS: one run, `run_<RID>` (status maps to `FAILED`). Proves `filter[error]` ->
154154
### 6. Attribution — `mint-token` -> JWT exchange records the acting user
155155

156156
```bash
157-
TOKEN=$(cli mint-token --profile $PROFILE --client errors-api-e2e 2>/dev/null) # UAT
157+
TOKEN=$(cli mint-token --profile $PROFILE --client errors-api-e2e --cap read:errors,write:errors 2>/dev/null) # UAT
158158
ENVJWT=$(curl -sS -X POST "$B/api/v1/projects/$REF/dev/jwt" -H "Authorization: Bearer $TOKEN" \
159159
-H 'Content-Type: application/json' -d '{"claims":{"scopes":["read:errors","write:errors"]}}' \
160160
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Generated, not hand-written: collapsed in diffs and excluded from language stats.
2+
internal-packages/dashboard-agent-db/drizzle/meta/*.json linguist-generated=true
3+
internal-packages/dashboard-agent-db/drizzle/meta/** linguist-generated=true
4+
**/__snapshots__/*.snap linguist-generated=true
5+
pnpm-lock.yaml linguist-generated=true

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,5 @@ ailogger-output.log
8585

8686
# observability-map CLI output artifact, not committed
8787
observability-map.json
88+
89+
.claude/worktrees/

.server-changes/dashboard-agent.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.
7+
8+
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.

apps/webapp/.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ node_modules
77
/cypress/screenshots
88
/cypress/videos
99

10+
# Output of `pnpm run agent-ui:screenshots`
11+
/screenshots
12+
1013
/app/styles/tailwind.css
1114

1215
# Ensure the .env symlink is not removed by accident
@@ -20,4 +23,4 @@ storybook-static
2023

2124
/prisma/seed.js
2225
/prisma/populate.js
23-
.memory-snapshots
26+
.memory-snapshots

apps/webapp/app/components/AskAI.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/**
2+
* @deprecated Superseded by the dashboard agent (`components/dashboard-agent`). Nothing mounts
3+
* this any more — every Ask AI entry point now opens Ask Trigger. Kept until the agent has
4+
* shipped, then removed along with `@kapaai/react-sdk` and `KAPA_AI_WEBSITE_ID`.
5+
*/
6+
17
import {
28
ArrowPathIcon,
39
ArrowUpIcon,
@@ -81,6 +87,8 @@ function useAskAIState() {
8187
* it around the popover, not inside, so the dialog and shortcut survive the popover closing.
8288
* `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no
8389
* Kapa website id, or SSR).
90+
*
91+
* @deprecated See the note at the top of this file.
8492
*/
8593
export function AskAIRoot({
8694
children,
@@ -137,6 +145,7 @@ function AskAIRootProvider({
137145
);
138146
}
139147

148+
/** @deprecated See the note at the top of this file. */
140149
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
141150
const { isManagedCloud } = useFeatures();
142151
const websiteId = useKapaWebsiteId();
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createElement } from "react";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { describe, expect, it } from "vitest";
4+
import { restrictModelUrls, StreamdownRenderer } from "./StreamdownRenderer";
5+
6+
// streamdown calls urlTransform(url, key, node) to compute each url attribute; a
7+
// returned undefined removes the attribute, so no request is ever issued.
8+
const img = { tagName: "img" } as any;
9+
const link = { tagName: "a" } as any;
10+
11+
describe("restrictModelUrls (image src)", () => {
12+
it("drops a remote model-authored image (the favicon beacon)", () => {
13+
expect(
14+
restrictModelUrls("https://www.google.com/s2/favicons?domain=evil", "src", img)
15+
).toBeUndefined();
16+
});
17+
18+
it("drops any absolute or protocol-relative remote image", () => {
19+
expect(restrictModelUrls("http://evil.tld/pixel.gif", "src", img)).toBeUndefined();
20+
expect(restrictModelUrls("//evil.tld/pixel.gif", "src", img)).toBeUndefined();
21+
});
22+
23+
it("keeps inline and same-origin images", () => {
24+
expect(restrictModelUrls("data:image/png;base64,AAAA", "src", img)).toBe(
25+
"data:image/png;base64,AAAA"
26+
);
27+
expect(restrictModelUrls("blob:abc", "src", img)).toBe("blob:abc");
28+
expect(restrictModelUrls("/local/pic.png", "src", img)).toBe("/local/pic.png");
29+
});
30+
});
31+
32+
describe("restrictModelUrls (link href)", () => {
33+
it("keeps http(s), mailto and relative links", () => {
34+
expect(restrictModelUrls("https://trigger.dev/docs", "href", link)).toBe(
35+
"https://trigger.dev/docs"
36+
);
37+
expect(restrictModelUrls("http://example.com", "href", link)).toBe("http://example.com");
38+
expect(restrictModelUrls("mailto:hi@trigger.dev", "href", link)).toBe("mailto:hi@trigger.dev");
39+
expect(restrictModelUrls("/runs/123", "href", link)).toBe("/runs/123");
40+
});
41+
42+
it("drops unsafe link schemes", () => {
43+
expect(restrictModelUrls("javascript:alert(1)", "href", link)).toBeUndefined();
44+
expect(restrictModelUrls("data:text/html,<script>", "href", link)).toBeUndefined();
45+
});
46+
});
47+
48+
// Force the lazy component to load, then return its resolved default so we can render it
49+
// synchronously. This proves the policy is actually wired into the JSX, not just exported.
50+
async function resolveStreamdownRenderer() {
51+
const lazy = StreamdownRenderer as unknown as {
52+
_payload: unknown;
53+
_init: (payload: unknown) => (props: { children: string }) => JSX.Element;
54+
};
55+
try {
56+
lazy._init(lazy._payload);
57+
} catch (thenable) {
58+
await thenable;
59+
}
60+
return lazy._init(lazy._payload);
61+
}
62+
63+
describe("StreamdownRenderer (rendered markdown)", () => {
64+
it("never lets a model-authored remote image src reach the DOM", async () => {
65+
const Renderer = await resolveStreamdownRenderer();
66+
const markdown = [
67+
"![x](https://www.google.com/s2/favicons?domain=SECRET.evil.tld)",
68+
"![y](//evil.tld/pixel.gif)",
69+
"![z](/local/pic.png)",
70+
].join("\n\n");
71+
const html = renderToStaticMarkup(createElement(Renderer, null, markdown));
72+
73+
// No remote host is ever fetched: no absolute or protocol-relative image src survives.
74+
expect(html).not.toContain('src="http');
75+
expect(html).not.toContain('src="//');
76+
expect(html).not.toContain("SECRET.evil.tld");
77+
// A same-origin relative image is untouched, so the policy does not over-block.
78+
expect(html).toContain('src="/local/pic.png"');
79+
});
80+
});

apps/webapp/app/components/code/StreamdownRenderer.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
import { lazy } from "react";
2-
import type { CodeHighlighterPlugin } from "streamdown";
2+
import type { CodeHighlighterPlugin, UrlTransform } from "streamdown";
3+
4+
const SAFE_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
5+
6+
/**
7+
* URL policy for model-authored markdown. A remote image is fetched the moment it
8+
* renders — no click — so it is a zero-click data beacon; we drop the src of any
9+
* non-local image. Links stay clickable but only for safe, human-followable schemes.
10+
* streamdown removes an attribute whose transform returns undefined, so no request fires.
11+
*/
12+
export const restrictModelUrls: UrlTransform = (url, key, node) => {
13+
const value = url.trim();
14+
const isImage = node.tagName === "img" || key === "src" || key === "srcset";
15+
16+
if (isImage) {
17+
// Inline images carry their own bytes; a relative path resolves to our own origin.
18+
if (/^data:/i.test(value) || /^blob:/i.test(value)) return url;
19+
// Absolute or protocol-relative means a remote host — strip it so nothing is fetched.
20+
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) return undefined;
21+
return url;
22+
}
23+
24+
// Links: relative and protocol-relative are fine; otherwise require a safe scheme.
25+
if (value.startsWith("//")) return url;
26+
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(value);
27+
if (!schemeMatch) return url;
28+
return SAFE_LINK_SCHEMES.has(`${schemeMatch[1].toLowerCase()}:`) ? url : undefined;
29+
};
330

431
export const StreamdownRenderer = lazy(() =>
532
Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then(
@@ -23,6 +50,7 @@ export const StreamdownRenderer = lazy(() =>
2350
isAnimating={isAnimating}
2451
plugins={{ code: codePlugin }}
2552
controls={{ code: { copy: false, download: false } }}
53+
urlTransform={restrictModelUrls}
2654
linkSafety={{ enabled: false }}
2755
>
2856
{children}

apps/webapp/app/components/code/tsql/tsqlLinter.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ describe("tsqlLinter", () => {
6868
expect(error).toContain("line");
6969
});
7070

71-
it("should handle missing FROM clause", () => {
72-
const error = getTSQLError("SELECT * WHERE id = 1");
73-
expect(error).not.toBeNull();
71+
it("should accept a query without a FROM clause", () => {
72+
// FROM is optional in the grammar (ClickHouse allows e.g. `SELECT 1`),
73+
// so a FROM-less SELECT is syntactically valid.
74+
expect(getTSQLError("SELECT * WHERE id = 1")).toBeNull();
7475
});
7576
});
7677
});

0 commit comments

Comments
 (0)