4569657923
## 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.
350 lines
12 KiB
TypeScript
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)`);
|
|
}
|
|
}
|