Files
triggerdotdev--trigger.dev/apps/webapp/server.ts
T
Katia Bulatova 4569657923 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.
2026-08-11 18:56:14 +02:00

350 lines
12 KiB
TypeScript

import "./sentry.server";
import { createRequestHandler } from "@remix-run/express";
import compression from "compression";
import type { Server as EngineServer } from "engine.io";
import express, { type RequestHandler } from "express";
import morgan from "morgan";
import { nanoid } from "nanoid";
import path from "path";
import { pathToFileURL } from "node:url";
import type { Server as IoServer } from "socket.io";
import type { WebSocketServer } from "ws";
import type { RateLimitMiddleware } from "~/services/apiRateLimit.server";
import { type RunWithHttpContextFunction } from "~/services/httpAsyncStorage.server";
import cluster from "node:cluster";
import os from "node:os";
const ENABLE_CLUSTER = process.env.ENABLE_CLUSTER === "1";
const cpuCount = os.availableParallelism();
const WORKERS =
Number.parseInt(process.env.WEB_CONCURRENCY || process.env.CLUSTER_WORKERS || "", 10) || cpuCount;
// Must be greater than the upstream load balancer's idle timeout to avoid the
// LB pipelining a request onto a connection Node has already closed (→ 502).
const HTTP_KEEPALIVE_TIMEOUT_MS =
Number.parseInt(process.env.HTTP_KEEPALIVE_TIMEOUT_MS || "", 10) || 65 * 1000;
function forkWorkers() {
for (let i = 0; i < WORKERS; i++) {
cluster.fork();
}
}
function installPrimarySignalHandlers() {
let didHandleSigterm = false;
let didHandleSigint = false;
let didGracefulExit = false;
const forward = (signal: NodeJS.Signals) => {
for (const id in cluster.workers) {
const w = cluster.workers[id];
if (w?.process?.pid) {
try {
process.kill(w.process.pid, signal);
} catch {}
}
}
};
const gracefulExit = () => {
if (didGracefulExit) return;
didGracefulExit = true;
const timeoutMs = Number(process.env.GRACEFUL_SHUTDOWN_TIMEOUT || 30_000);
// wait for workers to exit, then exit the primary too
const maybeExit = () => {
const alive = Object.values(cluster.workers || {}).some((w) => w && !w.isDead());
if (!alive) process.exit(0);
};
setInterval(maybeExit, 1000);
setTimeout(() => process.exit(0), timeoutMs);
};
process.on("SIGTERM", () => {
if (didHandleSigterm) return;
didHandleSigterm = true;
forward("SIGTERM");
gracefulExit();
});
process.on("SIGINT", () => {
if (didHandleSigint) return;
didHandleSigint = true;
forward("SIGINT");
gracefulExit();
});
}
// Bundled to CJS (esbuild rewrites import() to require); vite and the Remix
// server bundle are ESM, so load them via a real dynamic import.
const dynamicImport = new Function("specifier", "return import(specifier)") as (
specifier: string
) => Promise<any>;
if (ENABLE_CLUSTER && cluster.isPrimary) {
process.title = `node webapp-server primary`;
console.log(`[cluster] Primary ${process.pid} is starting with ${WORKERS} workers`);
forkWorkers();
cluster.on("exit", (worker, code, signal) => {
const intentional =
// If we sent "shutdown", the worker will exit with code 0 after closing.
code === 0 || worker.exitedAfterDisconnect;
console.log(
`[cluster] worker ${worker.process.pid} exited (code=${code}, signal=${signal}, intentional=${intentional})`
);
// If it wasn't during a shutdown, replace the worker.
if (!intentional) cluster.fork();
});
installPrimarySignalHandlers();
} else {
startServer().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
}
async function startServer() {
const app = express();
if (process.env.DISABLE_COMPRESSION !== "1") {
app.use(compression());
}
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable("x-powered-by");
const MODE = process.env.NODE_ENV;
// In development, Vite serves assets (and handles HMR) via middleware.
// Only NODE_ENV=development boots Vite — scripts that run the built server
// without NODE_ENV (start:local, dev:worker) must serve the build.
const viteDevServer =
MODE === "development"
? await dynamicImport("vite").then((vite) =>
vite.createServer({ server: { middlewareMode: true } })
)
: undefined;
if (viteDevServer) {
app.use(viteDevServer.middlewares);
} else {
// Vite fingerprints its assets so we can cache forever.
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
// Stale clients can request an old hashed asset; hard-404 instead of falling
// through to Remix and answering a .js request with HTML.
app.use("/assets", (_req, res) => {
res.status(404).end();
});
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static("build/client", { maxAge: "1h" }));
}
// On high-volume machine-ingest services (e.g. otel) the per-request access
// log dominates log volume. HTTP_ACCESS_LOG_DISABLED suppresses successful
// (2xx) access logs; non-2xx responses are always logged so errors stay visible.
const suppressSuccessfulAccessLogs = process.env.HTTP_ACCESS_LOG_DISABLED === "1";
app.use(
morgan("tiny", {
skip: (_req, res) =>
suppressSuccessfulAccessLogs && res.statusCode >= 200 && res.statusCode < 300,
})
);
process.title = ENABLE_CLUSTER
? `node webapp-worker-${cluster.isWorker ? cluster.worker?.id : "solo"}`
: "node webapp-server";
const loadBuild = () => {
if (viteDevServer) {
return viteDevServer.ssrLoadModule("virtual:remix/server-build");
}
return dynamicImport(
pathToFileURL(path.join(process.cwd(), "build", "server", "index.mjs")).href
);
};
// Boots the entry.server singletons (socket.io, wss, rate limiters).
const build = await loadBuild();
const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000;
if (process.env.HTTP_SERVER_DISABLED !== "true") {
// Back-compat shim: a previously-deployed client build polls this endpoint after a
// /build asset 404 and reloads once it reports a newer build id, letting those older
// tabs recover in a single reload. Temporary — safe to remove once older clients have
// churned out. Deliberately does NOT set an X-Build-Id response header.
app.get("/build-version", (_req, res) => {
res.set("Cache-Control", "no-store");
res.json({ version: build.assets.version });
});
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
const wss: WebSocketServer | undefined = build.entry.module.wss;
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
const deploymentRateLimiter: RateLimitMiddleware = build.entry.module.deploymentRateLimiter;
const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter;
const otlpRateLimiter: RequestHandler = build.entry.module.otlpRateLimiter;
const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext;
const tenantContextMiddleware: RequestHandler = build.entry.module.tenantContextMiddleware;
const dashboardAgentBodyCap: RequestHandler = build.entry.module.dashboardAgentBodyCap;
app.use((req, res, next) => {
// helpful headers:
res.set("Strict-Transport-Security", `max-age=${60 * 60 * 24 * 365 * 100}`);
// Add X-Robots-Tag header for test-cloud.trigger.dev
if (req.hostname !== "cloud.trigger.dev") {
res.set("X-Robots-Tag", "noindex, nofollow");
}
// /clean-urls/ -> /clean-urls. Skip /ph: PostHog ingest endpoints end in
// a slash, and a 301 would drop sendBeacon POSTs.
if (req.path.endsWith("/") && req.path.length > 1 && !req.path.startsWith("/ph/")) {
const query = req.url.slice(req.path.length);
const safepath = req.path.slice(0, -1).replace(/\/+/g, "/");
res.redirect(301, safepath + query);
return;
}
next();
});
app.use((req, res, next) => {
// Generate a unique request ID for each request
const requestId = nanoid();
const abortController = new AbortController();
res.on("close", () => abortController.abort());
runWithHttpContext(
{ requestId, path: req.url, host: req.hostname, method: req.method, abortController },
next
);
});
if (process.env.DASHBOARD_AND_API_DISABLED !== "true") {
if (process.env.ALLOW_ONLY_REALTIME_API === "true") {
// Block all requests that do not start with /realtime
app.use((req, res, next) => {
// Make sure /healthcheck is still accessible
if (!req.url.startsWith("/realtime") && req.url !== "/healthcheck") {
res.status(404).send("Not Found");
return;
}
next();
});
}
app.use(apiRateLimiter);
app.use(deploymentRateLimiter);
app.use(engineRateLimiter);
app.use(otlpRateLimiter);
app.use(tenantContextMiddleware);
// Before the Remix handler: the agent's chat body is refused while it streams, so a
// route never buffers one that was already too large.
app.use(dashboardAgentBodyCap);
app.all(
"*",
// @ts-ignore
createRequestHandler({
build: viteDevServer ? loadBuild : build,
mode: MODE,
})
);
} else {
// we need to do the health check here at /healthcheck — forward
// to the Remix handler so the loader's readiness checks (DB ping,
// REQUIRE_PLUGINS-gated plugin load) run in this mode too. A
// static 200 here would silently mask a failed plugin load.
app.get(
"/healthcheck",
// @ts-ignore
createRequestHandler({
build: viteDevServer ? loadBuild : build,
mode: MODE,
})
);
}
const server = app.listen(port, () => {
console.log(
`✅ server ready: http://localhost:${port} [NODE_ENV: ${MODE}]${
ENABLE_CLUSTER && cluster.isWorker ? ` [worker ${cluster.worker?.id}/${process.pid}]` : ""
}`
);
});
server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS;
// Mitigate against https://github.com/triggerdotdev/trigger.dev/security/dependabot/128
// by not allowing 2000+ headers to be sent and causing a DoS
// headers will instead be limited by the maxHeaderSize
server.maxHeadersCount = 0;
let didCloseServer = false;
function closeServer(signal: NodeJS.Signals) {
if (didCloseServer) return;
didCloseServer = true;
server.close((err) => {
if (err) {
console.error("Error closing express server:", err);
} else {
console.log("Express server closed gracefully.");
}
});
// Dev-only: release Vite's file watchers and HMR websocket
viteDevServer?.close();
}
process.on("SIGTERM", closeServer);
process.on("SIGINT", closeServer);
socketIo?.io.attach(server);
server.removeAllListeners("upgrade"); // prevent duplicate upgrades from listeners created by io.attach()
server.on("upgrade", async (req, socket, head) => {
console.log(`Attemping to upgrade connection at url ${req.url}`);
socket.on("error", (err) => {
console.error("Connection upgrade error:", err);
});
const url = new URL(req.url ?? "", "http://localhost");
// Upgrade socket.io connection
if (url.pathname.startsWith("/socket.io/")) {
console.log(`Socket.io client connected, upgrading their connection...`);
// https://github.com/socketio/socket.io/issues/4693
(socketIo!.io.engine as EngineServer).handleUpgrade(req, socket, head);
return;
}
// Only upgrade the connecting if the path is `/ws`
if (url.pathname !== "/ws") {
// Setting the socket.destroy() error param causes an error event to be emitted which needs to be handled with socket.on("error") to prevent uncaught exceptions.
socket.destroy(
new Error(
"Cannot connect because of invalid path: Please include `/ws` in the path of your upgrade request."
)
);
return;
}
console.log(`Client connected, upgrading their connection...`);
// Handle the WebSocket connection
wss?.handleUpgrade(req, socket, head, (ws) => {
wss?.emit("connection", ws, req);
});
});
} else {
console.log(`✅ app ready (skipping http server)`);
}
}