diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts
index 6d7c1bd6f25..4c05f9e4b37 100644
--- a/apps/webapp/app/services/apiAuth.server.ts
+++ b/apps/webapp/app/services/apiAuth.server.ts
@@ -13,7 +13,8 @@ import {
findEnvironmentByPublicApiKey,
toAuthenticated,
} from "~/models/runtimeEnvironment.server";
-import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
+import type { RbacAbility, RbacResource, UserActorClaims } from "@trigger.dev/rbac";
+import { assertUserActorEnvironment } from "./userActorEnvironment.server";
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { logger } from "./logger.server";
import { safeEnvironmentLogFields } from "./safeEnvironmentLog";
@@ -44,6 +45,13 @@ const ClaimsSchema = z.object({
skipColumns: z.array(z.string()).optional(),
})
.optional(),
+ // Identity only. Authorization comes from `sub` and `scopes`, never from `act`.
+ act: z
+ .object({
+ sub: z.string(),
+ client: z.string().optional(),
+ })
+ .optional(),
});
// Re-export the slim shape defined in @trigger.dev/core. Single source of
@@ -74,6 +82,7 @@ export type ApiAuthenticationResultSuccess = {
// API keys (no user) and JWTs minted without delegation.
actor?: {
sub: string;
+ client?: string;
};
};
@@ -187,6 +196,7 @@ export async function authenticateApiKey(
environment: validationResults.environment,
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
+ actor: parsedClaims.success ? parsedClaims.data.act : undefined,
};
}
}
@@ -279,6 +289,7 @@ async function authenticateApiKeyWithFailure(
environment: validationResults.environment,
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
+ actor: parsedClaims.success ? parsedClaims.data.act : undefined,
};
}
}
@@ -394,10 +405,22 @@ function getApiKeyResult(apiKey: string): {
return { apiKey, type };
}
+/**
+ * The authenticated user-actor. A user-actor token authenticates as its user, so it is the same
+ * shape a PAT authenticates to โ that shape now carries the token's verified claims itself, so
+ * any layer holding the actor holds its environment scope.
+ */
+export type UserActorAuthenticatedActor = PersonalAccessTokenAuthenticationResult;
+
export type AuthenticationResult =
| {
type: "personalAccessToken";
- result: PersonalAccessTokenAuthenticationResult;
+ result: UserActorAuthenticatedActor;
+ /**
+ * Claims of the delegated user-actor token the caller presented, if any. A UAT authenticates
+ * as its user, so it rides on this variant; its environment scope is enforced on resolution.
+ */
+ userActor?: UserActorClaims;
}
| {
type: "organizationAccessToken";
@@ -522,11 +545,34 @@ export async function authenticateRequest<
return;
}
+/**
+ * Resolve the environment a request targets, and enforce the caller's environment scope.
+ *
+ * Every route that turns an authentication result into an environment goes through here, so the
+ * user-actor token's `environmentId` claim is checked once, at the seam โ a new endpoint can't
+ * forget it.
+ */
export async function authenticatedEnvironmentForAuthentication(
auth: AuthenticationResult,
projectRef: string,
slug: string,
branch?: string
+): Promise
{
+ const environment = await resolveEnvironmentForAuthentication(auth, projectRef, slug, branch);
+
+ if (auth.type === "personalAccessToken") {
+ // Either place the claims ride: on the actor (the shape every layer keeps) or beside it.
+ assertUserActorEnvironment(auth.result.userActor ?? auth.userActor, environment.id);
+ }
+
+ return environment;
+}
+
+async function resolveEnvironmentForAuthentication(
+ auth: AuthenticationResult,
+ projectRef: string,
+ slug: string,
+ branch?: string
): Promise {
if (slug === "staging") {
slug = "stg";
diff --git a/apps/webapp/app/services/dashboardAgent.server.ts b/apps/webapp/app/services/dashboardAgent.server.ts
index 039ee3c3ada..88eec884d5a 100644
--- a/apps/webapp/app/services/dashboardAgent.server.ts
+++ b/apps/webapp/app/services/dashboardAgent.server.ts
@@ -37,15 +37,35 @@ export function dashboardAgentApiOrigin(): string {
// service from the dashboard session (never a PAT), so a user can only ever
// mint a token for themselves. The `in` proxy injects this into the turn's
// metadata so the token reaches the agent without ever touching the browser.
-export function mintDashboardAgentUserActorToken(userId: string): Promise {
+//
+// Endpoints that bind something to one environment read `environmentId` off the token,
+// so the agent can't name a different one in a request body.
+export function mintDashboardAgentUserActorToken(
+ userId: string,
+ opts: { environmentId: string }
+): Promise {
return signUserActorToken(env.SESSION_SECRET, {
userId,
client: "dashboard-agent",
+ environmentId: opts.environmentId,
cap: DASHBOARD_AGENT_UAT_CAP,
expirationTime: Math.floor(Date.now() / 1000) + DASHBOARD_AGENT_UAT_TTL_SECONDS,
});
}
+// The API's env routes key on the canonical env name, not the dashboard URL slug
+// (staging's slug is "stg"). Anything handing the agent an environment maps through here.
+const ENV_NAME_BY_TYPE: Record = {
+ DEVELOPMENT: "dev",
+ STAGING: "staging",
+ PRODUCTION: "prod",
+ PREVIEW: "preview",
+};
+
+export function dashboardAgentEnvironmentName(type: string | undefined): string | undefined {
+ return type ? ENV_NAME_BY_TYPE[type] : undefined;
+}
+
// The session is created in whatever env DASHBOARD_AGENT_SECRET_KEY belongs to.
// baseURL is the Trigger instance this webapp runs against (its own API origin).
function dashboardAgentConfig() {
diff --git a/apps/webapp/app/services/dashboardAgentBodyCap.server.ts b/apps/webapp/app/services/dashboardAgentBodyCap.server.ts
new file mode 100644
index 00000000000..bc5d71202de
--- /dev/null
+++ b/apps/webapp/app/services/dashboardAgentBodyCap.server.ts
@@ -0,0 +1,70 @@
+import type { NextFunction, Request, Response } from "express";
+import {
+ MAX_MESSAGE_BODY_BYTES,
+ MESSAGE_TOO_LARGE_CODE,
+ MESSAGE_TOO_LARGE_ERROR,
+} from "~/components/dashboard-agent/message-limits";
+
+/**
+ * The ingress cap for the agent's chat paths. A route can only refuse a body after it has read
+ * it, and `content-length` is optional, so a chunked upload would be buffered whole before the
+ * route ever saw its size. This counts the bytes as they arrive and refuses mid-stream.
+ */
+
+/** Headroom over the message cap for multipart framing and the per-turn metadata. */
+const INGRESS_SLACK_BYTES = 8 * 1024;
+
+export const DASHBOARD_AGENT_MAX_INGRESS_BYTES = MAX_MESSAGE_BODY_BYTES + INGRESS_SLACK_BYTES;
+
+const AGENT_PATH = /\/dashboard-agent(\/|$)/;
+
+/** Methods that can carry one. GET and HEAD cannot, and streaming them would be wasted work. */
+const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
+
+function refuse(res: Response): void {
+ if (res.headersSent) return;
+ res.status(413).json({ error: MESSAGE_TOO_LARGE_ERROR, code: MESSAGE_TOO_LARGE_CODE });
+}
+
+/**
+ * Attaches a counting listener and pauses the stream again immediately, so the route's own
+ * reader still receives every chunk while nothing flows until it asks for it. Crossing the
+ * limit ends the request: pausing alone wouldn't stop the route resuming the stream itself.
+ */
+export function capRequestBody(req: Request, res: Response, limit: number): void {
+ const declared = Number.parseInt(req.headers["content-length"] ?? "", 10);
+ if (Number.isFinite(declared) && declared > limit) {
+ refuse(res);
+ return;
+ }
+
+ let received = 0;
+ const onData = (chunk: Buffer | string) => {
+ received += Buffer.byteLength(chunk);
+ if (received <= limit) return;
+ req.off("data", onData);
+ req.pause();
+ refuse(res);
+ // Torn down only once the refusal is on the wire, or the client never reads it.
+ res.once("finish", () => req.destroy());
+ };
+
+ req.on("data", onData);
+ req.pause();
+ req.once("end", () => req.off("data", onData));
+}
+
+/**
+ * Only the agent's own paths: every other route keeps the body handling it had. Matched
+ * case-insensitively because Remix routes are, and on every method โ a DELETE reads a body too.
+ */
+export function dashboardAgentBodyCap(req: Request, res: Response, next: NextFunction): void {
+ if (!BODY_METHODS.has(req.method) || !AGENT_PATH.test(req.path.toLowerCase())) {
+ next();
+ return;
+ }
+
+ capRequestBody(req, res, DASHBOARD_AGENT_MAX_INGRESS_BYTES);
+ if (res.headersSent) return;
+ next();
+}
diff --git a/apps/webapp/app/services/dashboardAgentEvalPolicy.server.ts b/apps/webapp/app/services/dashboardAgentEvalPolicy.server.ts
new file mode 100644
index 00000000000..e6bed989b21
--- /dev/null
+++ b/apps/webapp/app/services/dashboardAgentEvalPolicy.server.ts
@@ -0,0 +1,49 @@
+/**
+ * Whether an org's agent turns may be judged. The agent has no main-database access, so it
+ * asks the API for this and treats anything but an explicit yes as no.
+ */
+
+import { prisma } from "~/db.server";
+import { logger } from "~/services/logger.server";
+import { FEATURE_FLAG } from "~/v3/featureFlags";
+import { makeFlag } from "~/v3/featureFlags.server";
+
+/** Judging is on unless an org turns it off. */
+const DEFAULT_TURN_EVALS_ENABLED = true;
+
+/**
+ * Resolves `dashboardAgentTurnEvalsEnabled` for one org, with a per-org override winning in
+ * both directions. Membership-scoped: a token can name any org, so the caller's membership
+ * is the tenant floor. Returns false when the org (or its setting) can't be read โ a judged
+ * turn goes to a third-party model, so an unknown answer must not read as consent.
+ */
+export async function orgAllowsDashboardAgentTurnEvals(params: {
+ userId: string;
+ organizationId: string;
+}): Promise {
+ try {
+ const org = await prisma.organization.findFirst({
+ where: {
+ id: params.organizationId,
+ members: { some: { userId: params.userId } },
+ },
+ select: { featureFlags: true },
+ });
+ if (!org) return false;
+
+ const flag = makeFlag();
+ return Boolean(
+ await flag({
+ key: FEATURE_FLAG.dashboardAgentTurnEvalsEnabled,
+ defaultValue: DEFAULT_TURN_EVALS_ENABLED,
+ overrides: (org.featureFlags as Record) ?? {},
+ })
+ );
+ } catch (error) {
+ logger.error("Couldn't read the org's dashboard agent turn-eval setting", {
+ organizationId: params.organizationId,
+ error,
+ });
+ return false;
+ }
+}
diff --git a/apps/webapp/app/services/dashboardAgentEvalRetention.server.ts b/apps/webapp/app/services/dashboardAgentEvalRetention.server.ts
new file mode 100644
index 00000000000..5548842013a
--- /dev/null
+++ b/apps/webapp/app/services/dashboardAgentEvalRetention.server.ts
@@ -0,0 +1,58 @@
+/**
+ * Retention for the agent's judged-turn rows. The table is append-only quality data with
+ * no reader, so it can't be left to grow forever; one bounded statement per run, oldest
+ * first. Runs whether or not the agent is configured โ rows outlive the agent project.
+ */
+
+import { deleteTurnEvalsOlderThan } from "@internal/dashboard-agent-db";
+import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
+import { logger } from "~/services/logger.server";
+
+/**
+ * How long a judged turn is kept. Nothing reads the table today, and the rows carry the
+ * user's question next to the agent's answer, so the period is the shortest one that still
+ * lets a month of product review (capability and docs gaps) be aggregated.
+ */
+export const TURN_EVAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
+
+/** Per-run cap. Retention is one statement, not a row-at-a-time loop. */
+const RETENTION_BATCH_LIMIT = 500;
+
+export type TurnEvalRetentionResult = {
+ /** Rows past the retention period dropped this run. */
+ purged: number;
+ failed: number;
+};
+
+export type TurnEvalRetentionDeps = {
+ now?: () => Date;
+ limit?: number;
+ /** Drop rows created before `before`. Returns how many went. */
+ purge?: (params: { before: Date; limit: number }) => Promise;
+};
+
+export async function sweepDashboardAgentTurnEvals(
+ deps: TurnEvalRetentionDeps = {}
+): Promise {
+ const now = deps.now?.() ?? new Date();
+ const limit = deps.limit ?? RETENTION_BATCH_LIMIT;
+ const purge = deps.purge ?? ((params) => deleteTurnEvalsOlderThan(dashboardAgentDb, params));
+
+ const result: TurnEvalRetentionResult = { purged: 0, failed: 0 };
+
+ try {
+ result.purged = await purge({
+ before: new Date(now.getTime() - TURN_EVAL_RETENTION_MS),
+ limit,
+ });
+ } catch (error) {
+ result.failed++;
+ logger.error("Dashboard agent turn-eval retention failed", { error });
+ }
+
+ if (result.failed > 0) {
+ throw new Error("The dashboard agent turn-eval retention pass failed");
+ }
+
+ return result;
+}
diff --git a/apps/webapp/app/services/dashboardAgentHeadStart.server.ts b/apps/webapp/app/services/dashboardAgentHeadStart.server.ts
index 9c411c8040e..4a5936a196b 100644
--- a/apps/webapp/app/services/dashboardAgentHeadStart.server.ts
+++ b/apps/webapp/app/services/dashboardAgentHeadStart.server.ts
@@ -6,8 +6,14 @@ import {
dashboardAgentCodeToolSchemas,
dashboardAgentToolSchemas,
} from "@internal/dashboard-agent/tool-schemas";
+import {
+ describePromptPrefix,
+ PROMPT_CACHE_CONTROL,
+ promptCacheAttributes,
+} from "@internal/dashboard-agent/prompt-prefix";
+import { ApiClient, SessionStreamInstance, writeTurnCompleteRecord } from "@trigger.dev/core/v3";
import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
-import { streamText, type UIMessage } from "ai";
+import { streamText, type UIMessage, type UIMessageChunk } from "ai";
import { env } from "~/env.server";
import {
dashboardAgentApiOrigin,
@@ -19,17 +25,69 @@ const TASK_ID = "dashboard-agent";
const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY });
+/** Shown when the warm first turn produced nothing. The provider error is only logged. */
+export const HEAD_START_FAILURE_ERROR_TEXT =
+ "The assistant couldn't start this response. Please send your message again.";
+
+/** A seam so the failure path is testable without S2 credentials or a live session. */
+export type DashboardAgentSessionOutWriter = {
+ writeChunk(chunk: UIMessageChunk): Promise;
+ /** The `turn-complete` control record that closes the client's stream. */
+ writeTurnComplete(): Promise;
+};
+
/**
- * Server-owned head start. The webapp generates the chatId and owns the chat
- * record, then kicks off step 1 here via `chat.startHeadStart` (the detached
- * flow): it creates the session (externalId = chatId), triggers the
- * handover-prepare run, and streams step 1 into `session.out` in the background.
- * The browser resumes that stream rather than streaming step 1 inline. Step 1
- * runs the agent's SCHEMA-ONLY tools + the shared model/prompt for the mode the
- * agent run will be in; the agent run picks up tool execution and step 2+.
- *
- * `metadata` (the delegated UAT + context) is merged into the run's wire payload
- * server-side, so it reaches the agent without touching the browser.
+ * Surface a failed warm step 1 as a visible error turn. `turn-complete` is written even if
+ * the error chunk fails, so a resumed stream always terminates.
+ */
+export async function writeHeadStartFailureToSessionOut(
+ writer: DashboardAgentSessionOutWriter
+): Promise {
+ try {
+ await writer.writeChunk({
+ type: "error",
+ errorText: HEAD_START_FAILURE_ERROR_TEXT,
+ } as UIMessageChunk);
+ } finally {
+ await writer.writeTurnComplete();
+ }
+}
+
+function singleChunkStream(chunk: UIMessageChunk): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(chunk);
+ controller.close();
+ },
+ });
+}
+
+// Writes as the agent's own environment: `.out` appends are private-only.
+function createSessionOutWriter(
+ chatId: string,
+ accessToken: string
+): DashboardAgentSessionOutWriter {
+ const apiClient = new ApiClient(dashboardAgentApiOrigin(), accessToken);
+ return {
+ async writeChunk(chunk) {
+ const instance = new SessionStreamInstance({
+ apiClient,
+ baseUrl: apiClient.baseUrl,
+ sessionId: chatId, // Sessions are addressable by externalId (chatId).
+ io: "out",
+ source: singleChunkStream(chunk),
+ });
+ await instance.wait();
+ },
+ async writeTurnComplete() {
+ await writeTurnCompleteRecord(apiClient, chatId);
+ },
+ };
+}
+
+/**
+ * Server-owned head start: creates the session, triggers the handover-prepare run, and
+ * streams step 1 into `session.out`. `metadata` is merged into the run's payload server-side.
*/
export async function startDashboardAgentHeadStart(params: {
chatId: string;
@@ -47,8 +105,7 @@ export async function startDashboardAgentHeadStart(params: {
messages: params.messages,
metadata: params.metadata,
triggerConfig: dashboardAgentTriggerConfig(),
- // Scope session creation + the agent trigger to the agent's project/env. The
- // Anthropic key here only powers the warm step-1 call.
+ // Scopes session creation and the agent trigger to the agent's own environment.
apiClient: {
baseURL: dashboardAgentApiOrigin(),
accessToken: env.DASHBOARD_AGENT_SECRET_KEY,
@@ -57,16 +114,44 @@ export async function startDashboardAgentHeadStart(params: {
streamText({
...helper.toStreamTextOptions({ tools }),
model: anthropic(DASHBOARD_AGENT_MODEL),
- system,
+ // A structured system message, not a bare string: without provider options
+ // Anthropic neither writes nor reads the cache, so this call paid full price
+ // for the prefix and the agent's step 2 then paid for a fresh write. The tool
+ // key order is frozen (see `tool-schemas.ts`) so both prefixes are identical
+ // โ the logged fingerprint is how a drift becomes visible.
+ system: {
+ role: "system",
+ content: system,
+ providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
+ },
+ onStepFinish: (step) => {
+ logger.info(
+ "Dashboard agent prompt cache",
+ promptCacheAttributes({
+ source: "head-start",
+ usage: step.usage,
+ prefix: describePromptPrefix({ system, tools }),
+ })
+ );
+ },
}),
});
- // The webapp is long-lived, so step 1's drain + the handover dispatch run in
- // the background after this resolves (createSession + trigger have completed).
- // Log a warm-step failure for observability: startHeadStart has already fired
- // handover-skip so the agent run exits cleanly, but the client (mounted as
- // streaming) then resumes an empty session.out, so the turn looks lost.
- completion.catch((error) => {
+ // Step 1's drain and the handover dispatch continue in the background. On failure
+ // `startHeadStart` already fired handover-skip, so nothing else writes to session.out.
+ completion.catch(async (error) => {
logger.error("Dashboard agent head start failed", { chatId: params.chatId, error });
+
+ const accessToken = env.DASHBOARD_AGENT_SECRET_KEY;
+ if (!accessToken) return;
+
+ try {
+ await writeHeadStartFailureToSessionOut(createSessionOutWriter(params.chatId, accessToken));
+ } catch (writeError) {
+ logger.error("Failed to write dashboard agent head start error to session.out", {
+ chatId: params.chatId,
+ error: writeError,
+ });
+ }
});
}
diff --git a/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts b/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts
new file mode 100644
index 00000000000..b10ee6b06af
--- /dev/null
+++ b/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts
@@ -0,0 +1,112 @@
+/**
+ * The investigation backstop, for cards left `in_progress`. They settle as `inconclusive`,
+ * conditional on the row still being `in_progress`, so a concluding turn wins the race.
+ */
+
+import {
+ listStaleOpenInvestigations,
+ settleInvestigationAndCloseCard,
+ type Investigation,
+ type SettledInvestigationCard,
+} from "@internal/dashboard-agent-db";
+import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts";
+import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
+import { logger } from "~/services/logger.server";
+
+/**
+ * How long a card may sit `in_progress` before the sweep settles it. Must outlast the
+ * slowest live turn, which bumps `updated_at` on every revision.
+ */
+export const INVESTIGATION_STALE_MS = 30 * 60 * 1000;
+
+/** Per-run cap. Oldest first, so the rest land next run. */
+const SWEEP_BATCH_LIMIT = 100;
+
+export type InvestigationSweepResult = {
+ /** Stale `in_progress` rows seen. */
+ stale: number;
+ settled: number;
+ /** Settled rows whose closing card reached the chat. */
+ closed: number;
+ /** A turn (or another sweep) settled it first. */
+ alreadySettled: number;
+ failed: number;
+};
+
+export type InvestigationSweepDeps = {
+ now?: () => Date;
+ limit?: number;
+ listStale?: (params: { olderThan: Date; limit: number }) => Promise;
+ /**
+ * Settle one row and deliver its closing card as a single operation. Null when the
+ * row was no longer `in_progress`.
+ */
+ settleAndClose?: (params: {
+ id: string;
+ chatId: string;
+ note: string;
+ }) => Promise;
+};
+
+/**
+ * Settle every card `in_progress` past the grace window. Each row is handled on its own,
+ * and the run throws at the end if any failed so the job is retried.
+ */
+export async function sweepDashboardAgentInvestigations(
+ deps: InvestigationSweepDeps = {}
+): Promise {
+ const now = deps.now?.() ?? new Date();
+ const limit = deps.limit ?? SWEEP_BATCH_LIMIT;
+ const listStale =
+ deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params));
+ const settleAndClose =
+ deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params));
+
+ const result: InvestigationSweepResult = {
+ stale: 0,
+ settled: 0,
+ closed: 0,
+ alreadySettled: 0,
+ failed: 0,
+ };
+
+ const stale = await listStale({
+ olderThan: new Date(now.getTime() - INVESTIGATION_STALE_MS),
+ limit,
+ });
+ result.stale = stale.length;
+
+ for (const investigation of stale) {
+ try {
+ // Settling the row fixes nothing on its own: the chat renders the winning card
+ // from its own transcript, so an unappended settle is still a stuck spinner โ
+ // which is why both writes are one operation that rolls back together.
+ const outcome = await settleAndClose({
+ id: investigation.id,
+ chatId: investigation.chatId,
+ note: UNSETTLED_INVESTIGATION_NOTE,
+ });
+ if (!outcome) {
+ result.alreadySettled++;
+ continue;
+ }
+ result.settled++;
+ if (outcome.closed) result.closed++;
+ } catch (error) {
+ result.failed++;
+ logger.error("Dashboard agent investigation sweep: failed to settle an investigation", {
+ investigationId: investigation.id,
+ chatId: investigation.chatId,
+ error,
+ });
+ }
+ }
+
+ if (result.failed > 0) {
+ throw new Error(
+ `The dashboard agent investigation sweep failed on ${result.failed} investigations`
+ );
+ }
+
+ return result;
+}
diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts
index d80ca6fa3f4..e382c526ad0 100644
--- a/apps/webapp/app/services/personalAccessToken.server.ts
+++ b/apps/webapp/app/services/personalAccessToken.server.ts
@@ -6,7 +6,7 @@ import { logger } from "./logger.server";
import { rbac } from "./rbac.server";
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
import { env } from "~/env.server";
-import { isUserActorToken } from "@trigger.dev/rbac";
+import { isUserActorToken, type UserActorClaims, verifyUserActorToken } from "@trigger.dev/rbac";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
@@ -115,6 +115,11 @@ export async function revokePersonalAccessToken(tokenId: string, userId: string)
export type PersonalAccessTokenAuthenticationResult = {
userId: string;
+ /**
+ * Verified claims when the caller presented a delegated user-actor token. They ride on the
+ * result so no caller can hold the actor without its environment scope.
+ */
+ userActor?: UserActorClaims;
};
/**
@@ -171,7 +176,14 @@ export async function authenticateApiRequestWithPersonalAccessToken(
// The plugin verifies it (identity path โ no org context to floor against).
if (isUserActorToken(token)) {
const result = await rbac.authenticateUserActor(request, {});
- return result.ok ? { userId: result.userId } : undefined;
+ if (!result.ok) return undefined;
+
+ // The claims travel with the identity: a caller that only saw `{ userId }` would act with no
+ // environment scope to enforce. A plugin on an older contract omits them, so verify here.
+ const userActor = result.claims ?? (await verifyUserActorToken(env.SESSION_SECRET, token));
+ if (!userActor) return undefined;
+
+ return { userId: result.userId, userActor };
}
return authenticatePersonalAccessToken(token);
diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts
index 755da8ec3a3..986172775cb 100644
--- a/apps/webapp/app/services/queryService.server.ts
+++ b/apps/webapp/app/services/queryService.server.ts
@@ -70,7 +70,10 @@ export type ExecuteQueryOptions = Omit<
organizationId: string;
projectId: string;
environmentId: string;
- /** The scope of the query - determines tenant isolation */
+ /**
+ * The scope of the query - determines tenant isolation. Callers that take it from
+ * a request body must cap it against the credential first; see `v3/queryScope.ts`.
+ */
scope: QueryScope;
period?: string | null;
from?: string | null;
diff --git a/apps/webapp/app/services/resolveTriggerUri.server.ts b/apps/webapp/app/services/resolveTriggerUri.server.ts
new file mode 100644
index 00000000000..1fe94bb6d35
--- /dev/null
+++ b/apps/webapp/app/services/resolveTriggerUri.server.ts
@@ -0,0 +1,162 @@
+/**
+ * `trigger://` URI to dashboard link. Pure: a URI's `{env}` is a RuntimeEnvironment id but
+ * dashboard URLs need slugs, so the caller supplies the already-resolved scope.
+ */
+import {
+ safeParseTriggerUri,
+ type ParsedTriggerUri,
+ type TriggerUri,
+} from "@internal/dashboard-agent-contracts";
+import {
+ v3DeploymentVersionPath,
+ v3ErrorPath,
+ v3QueuesPath,
+ v3RunPath,
+ v3RunSpanPath,
+ v3RunsPath,
+} from "~/utils/pathBuilder";
+
+/** Structurally satisfied by `AuthenticatedEnvironment`. */
+export type TriggerUriScope = {
+ /** RuntimeEnvironment id. Must match the URI's `{env}` segment. */
+ id: string;
+ slug: string;
+ project: { slug: string; externalRef: string };
+ organization: { slug: string };
+ /** Only a `source` URI needs this. Omitted, a source URI resolves to nothing. */
+ repository?: { fullName?: string | null; remoteUrl?: string | null } | null;
+};
+
+export type ResolvedTriggerUri = {
+ label: string;
+ /** Dashboard path, relative to the app origin, unless `external` is set. */
+ url: string;
+ /** True when `url` is absolute and off the dashboard, so a host must not route it. */
+ external?: boolean;
+};
+
+/**
+ * Resolve one URI against one environment, returning `null` rather than guessing. A stored
+ * transcript can hold foreign URIs, which must never resolve into this project's URL space.
+ */
+export function resolveTriggerUri(
+ scope: TriggerUriScope,
+ uri: TriggerUri | string
+): ResolvedTriggerUri | null {
+ const parsed = safeParseTriggerUri(uri);
+ if (!parsed.success) return null;
+ if (!isInScope(scope, parsed.data)) return null;
+ return resolveInScope(scope, parsed.data);
+}
+
+const GITHUB_ORIGIN = "https://github.com";
+/** `owner/repo`, GitHub's own character set and nothing that could add a path. */
+const FULL_NAME = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
+
+/**
+ * The repository's canonical `https://github.com/{owner}/{repo}` base, or null. `remoteUrl` is
+ * normalized as the deployments UI does; anything but github.com is rejected, not guessed at.
+ */
+function githubRepoBaseUrl(repository: TriggerUriScope["repository"]): string | null {
+ const fullName = repository?.fullName?.trim();
+ if (fullName && FULL_NAME.test(fullName)) return `${GITHUB_ORIGIN}/${fullName}`;
+
+ const remoteUrl = repository?.remoteUrl?.trim();
+ if (!remoteUrl) return null;
+
+ const normalized = remoteUrl
+ .replace(/^git@github\.com:/, `${GITHUB_ORIGIN}/`)
+ .replace(/^ssh:\/\/git@github\.com\//, `${GITHUB_ORIGIN}/`)
+ .replace(/\.git$/, "");
+
+ let url: URL;
+ try {
+ url = new URL(normalized);
+ } catch {
+ return null;
+ }
+ if (url.hostname !== "github.com") return null;
+
+ const path = url.pathname.replace(/^\/+|\/+$/g, "");
+ if (!FULL_NAME.test(path)) return null;
+ return `${GITHUB_ORIGIN}/${path}`;
+}
+
+/** True when the URI names this exact project and environment. */
+function isInScope(scope: TriggerUriScope, parsed: ParsedTriggerUri): boolean {
+ return parsed.projectRef === scope.project.externalRef && parsed.environmentId === scope.id;
+}
+
+function resolveInScope(
+ scope: TriggerUriScope,
+ parsed: ParsedTriggerUri
+): ResolvedTriggerUri | null {
+ const { organization, project } = scope;
+ const environment = { slug: scope.slug };
+
+ switch (parsed.kind) {
+ case "runs":
+ // The navigate intent's `filters` become URL params at the host.
+ return {
+ label: "Runs",
+ url: v3RunsPath(organization, project, environment),
+ };
+ case "run":
+ return {
+ label: parsed.runId,
+ url: v3RunPath(organization, project, environment, { friendlyId: parsed.runId }),
+ };
+ case "span":
+ return {
+ label: `${parsed.runId} (${parsed.spanId})`,
+ url: v3RunSpanPath(
+ organization,
+ project,
+ environment,
+ { friendlyId: parsed.runId },
+ { spanId: parsed.spanId }
+ ),
+ };
+ case "error":
+ return {
+ label: parsed.fingerprint,
+ url: v3ErrorPath(organization, project, environment, { fingerprint: parsed.fingerprint }),
+ };
+ case "queue":
+ // The queue detail route is keyed by friendlyId, which a URI doesn't carry, so this
+ // resolves to the queues list filtered to the name.
+ return {
+ label: parsed.name,
+ url: `${v3QueuesPath(organization, project, environment)}?query=${encodeURIComponent(
+ parsed.name
+ )}`,
+ };
+ case "deployment":
+ return {
+ label: parsed.version,
+ url: v3DeploymentVersionPath(organization, project, environment, parsed.version),
+ };
+ case "source": {
+ // The URI pins the commit and repo-relative path; the connected repo says where that
+ // lives. Without a connection there is nothing to open.
+ const base = githubRepoBaseUrl(scope.repository);
+ const label = parsed.line === undefined ? parsed.path : `${parsed.path}:${parsed.line}`;
+ if (!base) return null;
+ const path = parsed.path.split("/").map(encodeURIComponent).join("/");
+ const fragment = parsed.line === undefined ? "" : `#L${parsed.line}`;
+ return {
+ label,
+ url: `${base}/blob/${encodeURIComponent(parsed.sha)}/${path}${fragment}`,
+ external: true,
+ };
+ }
+ // No dashboard page exists for these yet, so the caller renders a label with no link.
+ case "report":
+ case "investigation":
+ return null;
+ default: {
+ const unreachable: never = parsed;
+ throw new Error(`Unhandled trigger:// kind: ${JSON.stringify(unreachable)}`);
+ }
+ }
+}
diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
index 83212a6e8b2..0fc3e83a93c 100644
--- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
+++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
@@ -1,5 +1,8 @@
import type { z } from "zod";
-import type { ApiAuthenticationResultSuccess } from "../apiAuth.server";
+import type {
+ ApiAuthenticationResultSuccess,
+ UserActorAuthenticatedActor,
+} from "../apiAuth.server";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
@@ -7,10 +10,11 @@ import { apiCors } from "~/utils/apiCors";
import { logger } from "../logger.server";
import { rbac } from "../rbac.server";
import { authenticateBearerWithTelemetry } from "~/services/authTelemetry.server";
-import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
-import { isUserActorToken } from "@trigger.dev/rbac";
-import type { PersonalAccessTokenAuthenticationResult } from "../personalAccessToken.server";
+import type { RbacAbility, RbacResource, UserActorClaims } from "@trigger.dev/rbac";
+import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/rbac";
import { updateLastAccessedAtIfStale } from "../personalAccessToken.server";
+import { assertUserActorScope } from "../userActorEnvironment.server";
+import { env } from "~/env.server";
import { safeJsonParse } from "~/utils/json";
import type { AuthenticatedWorkerInstance } from "~/v3/services/worker/workerGroupTokenService.server";
import { WorkerGroupTokenService } from "~/v3/services/worker/workerGroupTokenService.server";
@@ -422,7 +426,10 @@ export function createLoaderApiRoute<
const apiVersion = getApiVersion(request);
const result = await tenantContext.run(
- tenantContextFromAuthEnvironment(authenticationResult.environment),
+ tenantContextFromAuthEnvironment(
+ authenticationResult.environment,
+ authenticationResult.actor
+ ),
() =>
handler({
params: parsedParams,
@@ -458,6 +465,19 @@ export function createLoaderApiRoute<
};
}
+// `environmentId` is checked against a user-actor token's environment claim, so an env-scoped
+// route enforces the scope by declaring it here.
+type PATRouteContext = { organizationId?: string; projectId?: string; environmentId?: string };
+
+// Fail closed: a plugin built against an older contract returns no claims, so verify here rather
+// than continue with no environment scope to enforce.
+async function resolveUserActorClaims(
+ claims: UserActorClaims | undefined,
+ bearer: string
+): Promise {
+ return claims ?? (await verifyUserActorToken(env.SESSION_SECRET, bearer));
+}
+
type PATRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
@@ -479,9 +499,7 @@ type PATRouteBuilderOptions<
? z.infer
: undefined,
request: Request
- ) =>
- | { organizationId?: string; projectId?: string }
- | Promise<{ organizationId?: string; projectId?: string }>;
+ ) => PATRouteContext | Promise;
authorization?: {
action: string;
resource: (
@@ -516,7 +534,7 @@ type PATHandlerFunction<
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
? z.infer
: undefined;
- authentication: PersonalAccessTokenAuthenticationResult;
+ authentication: UserActorAuthenticatedActor;
ability: RbacAbility;
request: Request;
apiVersion: API_VERSIONS;
@@ -614,7 +632,7 @@ export function createLoaderPATApiRoute<
// cached timestamp is fresher than the throttle window).
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
- let authenticationResult: PersonalAccessTokenAuthenticationResult;
+ let authenticationResult: UserActorAuthenticatedActor;
let ability: RbacAbility;
const bearer = request.headers
@@ -632,7 +650,16 @@ export function createLoaderPATApiRoute<
corsStrategy !== "none"
);
}
- authenticationResult = { userId: uatAuth.userId };
+ const claims = await resolveUserActorClaims(uatAuth.claims, bearer);
+ if (!claims) {
+ return await wrapResponse(
+ request,
+ json({ error: "Invalid user-actor token" }, { status: 401 }),
+ corsStrategy !== "none"
+ );
+ }
+ await assertUserActorScope(claims, ctx);
+ authenticationResult = { userId: uatAuth.userId, userActor: claims };
ability = uatAuth.ability;
} else {
// PAT: validate + compute the cap-and-floor ability in one query.
@@ -743,7 +770,7 @@ type PATActionHandlerFunction<
body: TBodySchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
? z.infer
: undefined;
- authentication: PersonalAccessTokenAuthenticationResult;
+ authentication: UserActorAuthenticatedActor;
ability: RbacAbility;
request: Request;
apiVersion: API_VERSIONS;
@@ -879,7 +906,7 @@ export function createActionPATApiRoute<
// caller's role floor for the cap intersection (see the loader builder).
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
- let authenticationResult: PersonalAccessTokenAuthenticationResult;
+ let authenticationResult: UserActorAuthenticatedActor;
let ability: RbacAbility;
const bearer = request.headers
@@ -895,7 +922,16 @@ export function createActionPATApiRoute<
corsStrategy !== "none"
);
}
- authenticationResult = { userId: uatAuth.userId };
+ const claims = await resolveUserActorClaims(uatAuth.claims, bearer);
+ if (!claims) {
+ return await wrapResponse(
+ request,
+ json({ error: "Invalid user-actor token" }, { status: 401 }),
+ corsStrategy !== "none"
+ );
+ }
+ await assertUserActorScope(claims, ctx);
+ authenticationResult = { userId: uatAuth.userId, userActor: claims };
ability = uatAuth.ability;
} else {
const patAuth = await rbac.authenticatePat(request, ctx);
@@ -1278,7 +1314,10 @@ export function createActionApiRoute<
}
const result = await tenantContext.run(
- tenantContextFromAuthEnvironment(authenticationResult.environment),
+ tenantContextFromAuthEnvironment(
+ authenticationResult.environment,
+ authenticationResult.actor
+ ),
() =>
handler({
params: parsedParams,
@@ -1543,7 +1582,10 @@ export function createMultiMethodApiRoute<
// Dispatch to method handler
const result = await tenantContext.run(
- tenantContextFromAuthEnvironment(authenticationResult.environment),
+ tenantContextFromAuthEnvironment(
+ authenticationResult.environment,
+ authenticationResult.actor
+ ),
() =>
methodConfig.handler({
params: parsedParams,
diff --git a/apps/webapp/app/services/tenantContext.server.ts b/apps/webapp/app/services/tenantContext.server.ts
index 0cadaa9f4c2..70569167b57 100644
--- a/apps/webapp/app/services/tenantContext.server.ts
+++ b/apps/webapp/app/services/tenantContext.server.ts
@@ -1,12 +1,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { AuthenticatedEnvironment } from "./apiAuth.server";
-// All fields are optional. The middleware establishes an empty scope per
-// request; entry points fill what they know:
-// - URL-matching paths get the slug trio from the Express middleware (zero IO).
-// - The `_app` layout adds `userId` for any authenticated request.
-// - The env layout adds tenant IDs / env type after its own existing DB query.
-// - API routes get the full set up-front from `authenticationResult.environment`.
+// Every field is optional: each entry point fills only what it already knows.
export type TenantContext = {
userId?: string;
orgSlug?: string;
@@ -35,9 +30,13 @@ export const tenantContext = {
},
};
-export function tenantContextFromAuthEnvironment(env: AuthenticatedEnvironment): TenantContext {
+// `actor` wins over `orgMember`, which only exists on dev environments.
+export function tenantContextFromAuthEnvironment(
+ env: AuthenticatedEnvironment,
+ actor?: { sub: string }
+): TenantContext {
return {
- userId: env.orgMember?.userId,
+ userId: actor?.sub ?? env.orgMember?.userId,
orgSlug: env.organization.slug,
projectSlug: env.project.slug,
envSlug: env.slug,
diff --git a/apps/webapp/app/services/uatRoutePreamble.server.ts b/apps/webapp/app/services/uatRoutePreamble.server.ts
new file mode 100644
index 00000000000..3830556b0e4
--- /dev/null
+++ b/apps/webapp/app/services/uatRoutePreamble.server.ts
@@ -0,0 +1,46 @@
+import { isUserActorToken, verifyUserActorToken, type UserActorClaims } from "@trigger.dev/rbac";
+import { env } from "~/env.server";
+import { authenticateRequest, type AuthenticationResult } from "~/services/apiAuth.server";
+
+/**
+ * Auth preamble for `api.v1` routes that opt into delegated user-actor tokens alongside a PAT
+ * or org token. A UAT authenticates as its user, so the result is the `personalAccessToken` shape.
+ */
+export type UatAuthentication = {
+ authenticationResult: AuthenticationResult;
+ /** Present only when the caller presented a user-actor token. */
+ userActor?: UserActorClaims;
+};
+
+export async function authenticateUatOrApiRequest(
+ request: Request
+): Promise {
+ const bearer = request.headers
+ .get("Authorization")
+ ?.replace(/^Bearer /, "")
+ .trim();
+
+ if (bearer && isUserActorToken(bearer)) {
+ const claims = await verifyUserActorToken(env.SESSION_SECRET, bearer);
+ if (!claims) return undefined;
+ return {
+ // The claims ride on the authentication result too: resolving an environment from it
+ // enforces the token's environment scope, so no route has to remember to.
+ authenticationResult: {
+ type: "personalAccessToken",
+ result: { userId: claims.userId },
+ userActor: claims,
+ },
+ userActor: claims,
+ };
+ }
+
+ const authenticationResult = await authenticateRequest(request, {
+ personalAccessToken: true,
+ organizationAccessToken: true,
+ apiKey: false,
+ });
+ if (!authenticationResult) return undefined;
+
+ return { authenticationResult };
+}
diff --git a/apps/webapp/app/services/userActorEnvironment.server.ts b/apps/webapp/app/services/userActorEnvironment.server.ts
new file mode 100644
index 00000000000..f5abbfbc21f
--- /dev/null
+++ b/apps/webapp/app/services/userActorEnvironment.server.ts
@@ -0,0 +1,151 @@
+/**
+ * The Dashboard Agent uses an environment-scoped form of the existing user-actor credential.
+ * MCP and the CLI may use their existing ones. Both normalize into the same authorized
+ * capability context, so every route calls in here rather than deriving the rule itself.
+ *
+ * The rule: a token signed for one environment may only act inside it. Anything with no claim
+ * is environment-agnostic and unaffected โ except a dashboard-agent token, which always carries
+ * one, so its absence is a failed mint rather than a flow. Mismatches throw a 403 Response.
+ */
+
+import { json } from "@remix-run/server-runtime";
+import {
+ buildJwtAbility,
+ type RbacAbility,
+ scopesWithinAbility,
+ type UserActorClaims,
+} from "@trigger.dev/rbac";
+import { $replica } from "~/db.server";
+
+export const FORBIDDEN_ENVIRONMENT_CODE = "forbidden_environment";
+
+const DASHBOARD_AGENT_CLIENT = "dashboard-agent";
+
+export function assertUserActorEnvironment(
+ userActor: UserActorClaims | undefined,
+ environmentId: string
+): void {
+ if (!userActor) return;
+ if (!userActor.environmentId) {
+ assertClaimIsOptional(userActor);
+ return;
+ }
+ if (userActor.environmentId === environmentId) return;
+
+ throw forbiddenEnvironment("This token isn't scoped to that environment.");
+}
+
+/** The same check for a route that names an org/project rather than one environment. */
+export async function assertUserActorScope(
+ userActor: UserActorClaims | undefined,
+ scope: { organizationId?: string; projectId?: string; environmentId?: string }
+): Promise {
+ if (!userActor) return;
+
+ if (!userActor.environmentId) {
+ assertClaimIsOptional(userActor);
+ return;
+ }
+
+ if (scope.environmentId) {
+ assertUserActorEnvironment(userActor, scope.environmentId);
+ return;
+ }
+
+ if (!scope.organizationId && !scope.projectId) return;
+
+ const environment = await $replica.runtimeEnvironment.findFirst({
+ where: { id: userActor.environmentId },
+ select: { organizationId: true, projectId: true },
+ });
+
+ // A claim naming an environment that no longer exists cannot be checked, so it isn't honoured.
+ if (!environment) {
+ throw forbiddenEnvironment("This token isn't scoped to an environment.");
+ }
+ if (scope.projectId && environment.projectId !== scope.projectId) {
+ throw forbiddenEnvironment("This token isn't scoped to that project.");
+ }
+ if (scope.organizationId && environment.organizationId !== scope.organizationId) {
+ throw forbiddenEnvironment("This token isn't scoped to that organization.");
+ }
+}
+
+/** `scoped: false` keeps the project-wide answer every claimless caller already gets. */
+export type UserActorEnvironmentScope =
+ | { scoped: false }
+ | { scoped: true; environmentId: string; slug: string; organizationId: string };
+
+/**
+ * The claim as a mandatory filter for a route that lists across a project. A conflicting request
+ * filter is refused rather than overridden, so a caller never gets another environment's answer.
+ */
+export async function resolveUserActorEnvironmentScope(
+ userActor: UserActorClaims | undefined,
+ target: { projectId: string; requestedEnvironmentSlugs?: string[] }
+): Promise {
+ if (!userActor) return { scoped: false };
+
+ if (!userActor.environmentId) {
+ assertClaimIsOptional(userActor);
+ return { scoped: false };
+ }
+
+ const environment = await $replica.runtimeEnvironment.findFirst({
+ where: { id: userActor.environmentId, projectId: target.projectId },
+ select: { id: true, slug: true, organizationId: true },
+ });
+
+ // A claim naming an environment that can't be found in this project isn't honoured.
+ if (!environment) {
+ throw forbiddenEnvironment("This token isn't scoped to that project.");
+ }
+
+ const requested = target.requestedEnvironmentSlugs;
+ if (requested && (requested.length !== 1 || requested[0] !== environment.slug)) {
+ throw forbiddenEnvironment(`This token is scoped to the "${environment.slug}" environment.`);
+ }
+
+ return {
+ scoped: true,
+ environmentId: environment.id,
+ slug: environment.slug,
+ organizationId: environment.organizationId,
+ };
+}
+
+/** Mirrors the RBAC fallback's own default. */
+const CAPLESS_USER_ACTOR_SCOPES = ["read:all"];
+
+/**
+ * A delegated token must never mint something more capable than itself. Two ceilings apply:
+ * the actor's own ability (their role) and the token's `cap`. The role alone is not enough โ
+ * a read-only agent token belongs to a user who may well be allowed to write.
+ */
+export function clampUserActorScopes(
+ requestedScopes: string[] | undefined,
+ userActor: UserActorClaims,
+ ability: RbacAbility
+): { scopes: string[]; deniedScopes: string[] } {
+ const cap = userActor.cap ?? CAPLESS_USER_ACTOR_SCOPES;
+ const requested = requestedScopes && requestedScopes.length > 0 ? requestedScopes : cap;
+
+ const denied = new Set([
+ ...scopesWithinAbility(requested, ability).deniedScopes,
+ ...scopesWithinAbility(requested, buildJwtAbility(cap)).deniedScopes,
+ ]);
+
+ return {
+ scopes: requested.filter((scope) => !denied.has(scope)),
+ deniedScopes: [...denied],
+ };
+}
+
+function assertClaimIsOptional(userActor: UserActorClaims): void {
+ if (userActor.client !== DASHBOARD_AGENT_CLIENT) return;
+ throw forbiddenEnvironment("This token isn't scoped to an environment.");
+}
+
+function forbiddenEnvironment(error: string) {
+ return json({ error, code: FORBIDDEN_ENVIRONMENT_CODE }, { status: 403 });
+}
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index 174a9a9ff5e..5dd7f08ce1c 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -701,9 +701,10 @@
& li {
@apply my-0.5;
}
- /* Inline code (not in pre blocks) */
+ /* Theme-mapped, not raw charcoal: fenced blocks stay dark via shiki. */
& code:not(pre code) {
- @apply bg-charcoal-700 px-1 py-0.5 rounded-sm text-text-bright font-mono;
+ @apply px-1 py-0.5 rounded-sm text-text-bright font-mono;
+ background-color: var(--muted);
}
& blockquote {
@apply border-l-2 border-charcoal-600 pl-3 my-2 italic;
@@ -725,10 +726,11 @@
}
& th,
& td {
- @apply border border-charcoal-600 px-2 py-1 text-left;
+ @apply border border-grid-bright px-2 py-1 text-left;
}
& th {
- @apply bg-charcoal-700 font-semibold;
+ @apply font-semibold;
+ background-color: var(--muted);
}
& [data-code-block-header] {
@@ -740,7 +742,7 @@
@apply scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600;
}
& [data-code-block] pre code {
- @apply bg-transparent;
+ @apply bg-transparent text-xs;
}
& [data-code-block] .line {
@apply leading-relaxed;
diff --git a/apps/webapp/app/utils/boundedRequestBody.server.test.ts b/apps/webapp/app/utils/boundedRequestBody.server.test.ts
new file mode 100644
index 00000000000..d7c9269dab8
--- /dev/null
+++ b/apps/webapp/app/utils/boundedRequestBody.server.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { readBoundedBodyText } from "./boundedRequestBody.server";
+
+/** A body with no `content-length`, delivered in chunks, counting what was pulled. */
+function streamed(chunkCount: number, chunkBytes: number) {
+ let pulled = 0;
+ const body = new ReadableStream({
+ pull(controller) {
+ if (pulled >= chunkCount) {
+ controller.close();
+ return;
+ }
+ pulled += 1;
+ controller.enqueue(new Uint8Array(chunkBytes).fill(97));
+ },
+ });
+
+ const request = new Request("http://localhost/in", {
+ method: "POST",
+ body,
+ // @ts-expect-error โ required for a streamed request body.
+ duplex: "half",
+ });
+ return { request, pulled: () => pulled };
+}
+
+describe("readBoundedBodyText", () => {
+ it("returns a body under the limit", async () => {
+ const { request } = streamed(2, 8);
+ expect(await readBoundedBodyText(request, 1024)).toEqual({ ok: true, text: "a".repeat(16) });
+ });
+
+ it("stops reading as soon as the limit is crossed", async () => {
+ const { request, pulled } = streamed(100, 64);
+
+ expect(await readBoundedBodyText(request, 128)).toEqual({ ok: false, reason: "too_large" });
+ // Three chunks: two fit, the third crossed it. Nothing beyond was ever pulled.
+ expect(pulled()).toBe(3);
+ });
+
+ it("treats a missing body as empty", async () => {
+ const request = new Request("http://localhost/in", { method: "POST" });
+ expect(await readBoundedBodyText(request, 8)).toEqual({ ok: true, text: "" });
+ });
+});
diff --git a/apps/webapp/app/utils/boundedRequestBody.server.ts b/apps/webapp/app/utils/boundedRequestBody.server.ts
new file mode 100644
index 00000000000..395d490c003
--- /dev/null
+++ b/apps/webapp/app/utils/boundedRequestBody.server.ts
@@ -0,0 +1,35 @@
+/**
+ * Reading a request body with a ceiling. `request.text()` buffers the whole body before the
+ * caller can look at its size, so a route that only checks afterwards has already paid for it.
+ */
+export type BoundedBody = { ok: true; text: string } | { ok: false; reason: "too_large" };
+
+/** Stops at the first chunk that crosses `maxBytes` and cancels the stream. */
+export async function readBoundedBodyText(
+ request: Request,
+ maxBytes: number
+): Promise {
+ if (!request.body) return { ok: true, text: "" };
+
+ const reader = request.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let received = 0;
+
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (!value) continue;
+ received += value.byteLength;
+ if (received > maxBytes) {
+ await reader.cancel();
+ return { ok: false, reason: "too_large" };
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ return { ok: true, text: Buffer.concat(chunks).toString("utf8") };
+}
diff --git a/apps/webapp/app/utils/cspImageOrigins.test.ts b/apps/webapp/app/utils/cspImageOrigins.test.ts
new file mode 100644
index 00000000000..95391912d90
--- /dev/null
+++ b/apps/webapp/app/utils/cspImageOrigins.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "vitest";
+import {
+ BASE_IMG_SRC_SOURCES,
+ buildImgSrcDirective,
+ parseCspImageOrigins,
+ withImgSrc,
+} from "./cspImageOrigins";
+
+describe("parseCspImageOrigins", () => {
+ it("accepts exact https origins, with or without a port", () => {
+ const { origins, rejected } = parseCspImageOrigins(
+ "https://sso.example.com, https://images.example.com:8443"
+ );
+ expect(origins).toEqual(["https://sso.example.com", "https://images.example.com:8443"]);
+ expect(rejected).toEqual([]);
+ });
+
+ it("returns nothing when unset or empty", () => {
+ expect(parseCspImageOrigins(undefined).origins).toEqual([]);
+ expect(parseCspImageOrigins(" , ,").origins).toEqual([]);
+ });
+
+ it("deduplicates repeated origins", () => {
+ const { origins } = parseCspImageOrigins(
+ "https://sso.example.com,https://sso.example.com/,https://sso.example.com"
+ );
+ expect(origins).toEqual(["https://sso.example.com"]);
+ });
+
+ it.each([
+ ["*", "wildcards are not allowed, list each origin exactly"],
+ ["https://*.example.com", "wildcards are not allowed, list each origin exactly"],
+ ["https://example.com/avatars", "must be an origin only, with no path, query or hash"],
+ ["https://example.com?x=1", "must be an origin only, with no path, query or hash"],
+ ["https://example.com#frag", "must be an origin only, with no path, query or hash"],
+ ["example.com", "is not a valid absolute URL"],
+ ["https://user:pw@example.com", "must not contain credentials"],
+ ])("rejects %s and says why", (value, reason) => {
+ const { origins, rejected } = parseCspImageOrigins(value);
+ expect(origins).toEqual([]);
+ expect(rejected).toEqual([{ value, reason }]);
+ });
+
+ it("keeps the valid entries when a sibling entry is rejected", () => {
+ const { origins, rejected } = parseCspImageOrigins(
+ "https://*.evil.com,https://sso.example.com"
+ );
+ expect(origins).toEqual(["https://sso.example.com"]);
+ expect(rejected.map((entry) => entry.value)).toEqual(["https://*.evil.com"]);
+ });
+
+ it("rejects http outside development", () => {
+ const { origins, rejected } = parseCspImageOrigins("http://sso.example.com");
+ expect(origins).toEqual([]);
+ expect(rejected).toEqual([
+ { value: "http://sso.example.com", reason: 'scheme "http:" is not https:' },
+ ]);
+ });
+
+ it("allows http only when allowHttp is set", () => {
+ const { origins, rejected } = parseCspImageOrigins("http://localhost:4000", {
+ allowHttp: true,
+ });
+ expect(origins).toEqual(["http://localhost:4000"]);
+ expect(rejected).toEqual([]);
+ });
+
+ it("rejects a non-http scheme even when allowHttp is set", () => {
+ const { origins, rejected } = parseCspImageOrigins("ftp://example.com", { allowHttp: true });
+ expect(origins).toEqual([]);
+ expect(rejected).toEqual([
+ { value: "ftp://example.com", reason: 'scheme "ftp:" is not http: or https:' },
+ ]);
+ });
+});
+
+describe("buildImgSrcDirective", () => {
+ it("is self, data, blob and the GitHub avatar host by default", () => {
+ expect(buildImgSrcDirective()).toBe(
+ "img-src 'self' data: blob: https://avatars.githubusercontent.com"
+ );
+ });
+
+ it("has no wildcard host and no bare scheme host", () => {
+ const directive = buildImgSrcDirective(parseCspImageOrigins("https://sso.example.com").origins);
+ expect(directive).not.toContain("*");
+ expect(directive).not.toContain("googleusercontent.com");
+ expect(directive).not.toMatch(/(^|\s)https?:(\s|$)/);
+ });
+
+ it("appends configured origins after the base sources", () => {
+ expect(buildImgSrcDirective(["https://sso.example.com"])).toBe(
+ `img-src ${BASE_IMG_SRC_SOURCES.join(" ")} https://sso.example.com`
+ );
+ });
+});
+
+describe("withImgSrc", () => {
+ const directive = buildImgSrcDirective();
+
+ it("is the whole policy when a route set nothing", () => {
+ expect(withImgSrc(null, directive)).toBe(directive);
+ });
+
+ it("appends to a policy that has other directives", () => {
+ expect(withImgSrc("frame-ancestors 'self';", directive)).toBe(
+ `frame-ancestors 'self'; ${directive}`
+ );
+ });
+
+ it("leaves a route's own img-src untouched", () => {
+ const routePolicy = "img-src 'none'";
+ expect(withImgSrc(routePolicy, directive)).toBe(routePolicy);
+ expect(withImgSrc("default-src 'self'; img-src 'none'", directive)).toBe(
+ "default-src 'self'; img-src 'none'"
+ );
+ });
+});
diff --git a/apps/webapp/app/utils/cspImageOrigins.ts b/apps/webapp/app/utils/cspImageOrigins.ts
new file mode 100644
index 00000000000..3afeb306621
--- /dev/null
+++ b/apps/webapp/app/utils/cspImageOrigins.ts
@@ -0,0 +1,108 @@
+/**
+ * The document `img-src` allowlist. Remote images are a beacon channel: rendering
+ * one is the outbound request, no click needed. So the list is exact origins only โ
+ * no wildcard host, no bare scheme, nothing with a path.
+ */
+
+/** Always allowed: own origin, inline data, object URLs, and the GitHub avatar host. */
+export const BASE_IMG_SRC_SOURCES = [
+ "'self'",
+ "data:",
+ "blob:",
+ "https://avatars.githubusercontent.com",
+] as const;
+
+export type RejectedOrigin = { value: string; reason: string };
+
+export type ParsedImageOrigins = {
+ /** Accepted, canonicalised (`scheme://host[:port]`) and deduplicated. */
+ origins: string[];
+ rejected: RejectedOrigin[];
+};
+
+export type ParseImageOriginsOptions = {
+ /** Only a local development deployment may serve images over plain http. */
+ allowHttp?: boolean;
+};
+
+/**
+ * Parses a comma-separated `CSP_IMG_SRC_ALLOWLIST`. Never throws: bad entries are
+ * reported in `rejected` so the caller can warn and boot with the valid ones.
+ */
+export function parseCspImageOrigins(
+ raw: string | undefined | null,
+ options: ParseImageOriginsOptions = {}
+): ParsedImageOrigins {
+ const allowHttp = options.allowHttp ?? false;
+ const origins: string[] = [];
+ const seen = new Set();
+ const rejected: RejectedOrigin[] = [];
+
+ for (const entry of (raw ?? "").split(",")) {
+ const value = entry.trim();
+ if (value.length === 0) continue;
+
+ const reason = rejectionReason(value, allowHttp);
+ if (reason) {
+ rejected.push({ value, reason });
+ continue;
+ }
+
+ const url = new URL(value);
+ const origin = `${url.protocol}//${url.host}`;
+ if (seen.has(origin)) continue;
+ seen.add(origin);
+ origins.push(origin);
+ }
+
+ return { origins, rejected };
+}
+
+/** Returns why the entry is not an acceptable origin, or undefined if it is one. */
+function rejectionReason(value: string, allowHttp: boolean): string | undefined {
+ if (value.includes("*")) {
+ return "wildcards are not allowed, list each origin exactly";
+ }
+ if (/\s/.test(value)) {
+ return "contains whitespace";
+ }
+
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ return "is not a valid absolute URL";
+ }
+
+ const allowedProtocols = allowHttp ? ["https:", "http:"] : ["https:"];
+ if (!allowedProtocols.includes(url.protocol)) {
+ return allowHttp
+ ? `scheme "${url.protocol}" is not http: or https:`
+ : `scheme "${url.protocol}" is not https:`;
+ }
+ if (url.host.length === 0) {
+ return "has no host";
+ }
+ if (url.username.length > 0 || url.password.length > 0) {
+ return "must not contain credentials";
+ }
+ if (url.pathname !== "/" || url.search.length > 0 || url.hash.length > 0) {
+ return "must be an origin only, with no path, query or hash";
+ }
+ return undefined;
+}
+
+/** The full directive: the base sources plus any configured extra origins. */
+export function buildImgSrcDirective(extraOrigins: readonly string[] = []): string {
+ return ["img-src", ...BASE_IMG_SRC_SOURCES, ...extraOrigins].join(" ");
+}
+
+/**
+ * Appends the directive to whatever a route already set, rather than replacing it.
+ * A route that set its own `img-src` keeps it.
+ */
+export function withImgSrc(existing: string | null | undefined, directive: string): string {
+ if (!existing) return directive;
+ if (/(^|;)\s*img-src\s/.test(existing)) return existing;
+ return `${existing.replace(/;\s*$/, "")}; ${directive}`;
+}
diff --git a/apps/webapp/app/v3/canAccessDashboardAgent.server.ts b/apps/webapp/app/v3/canAccessDashboardAgent.server.ts
index dd3f4b0769a..26b2f84456c 100644
--- a/apps/webapp/app/v3/canAccessDashboardAgent.server.ts
+++ b/apps/webapp/app/v3/canAccessDashboardAgent.server.ts
@@ -4,20 +4,15 @@ import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
/**
- * Whether the in-dashboard AI agent is available to this user in this org.
- * Gated by the global / per-org `hasDashboardAgentAccess` flag, with
- * `DASHBOARD_AGENT_ENABLED` as the global default (a per-org override wins).
- * Admins/impersonators bypass it only when `DASHBOARD_AGENT_ADMIN_PREVIEW` is on
- * (default off). Enforced server-side so a non-flagged user can't start sessions.
+ * Whether the in-dashboard AI agent is available to this user in this org, per the
+ * `hasDashboardAgentAccess` flag with a per-org override winning. Must stay server-side.
*/
export async function canAccessDashboardAgent(options: {
userId: string;
isAdmin: boolean;
isImpersonating: boolean;
organizationSlug: string;
- // When the caller already has the org's `featureFlags` loaded (e.g. a layout
- // loader that queried the org with a membership check), pass them to skip the
- // extra org lookup. Omit it and we query the org ourselves.
+ // The org's already-loaded `featureFlags`. Omitted means we query the org ourselves.
orgFeatureFlags?: Record | null;
}): Promise {
const { userId, isAdmin, isImpersonating, organizationSlug, orgFeatureFlags } = options;
diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts
index 87093c36aae..65b59209ec5 100644
--- a/apps/webapp/app/v3/commonWorker.server.ts
+++ b/apps/webapp/app/v3/commonWorker.server.ts
@@ -1,5 +1,5 @@
import { Logger } from "@trigger.dev/core/logger";
-import { Worker as RedisWorker } from "@trigger.dev/redis-worker";
+import { CronSchema, Worker as RedisWorker } from "@trigger.dev/redis-worker";
import { DeliverEmailSchema } from "emails";
import { z } from "zod";
import { env } from "~/env.server";
@@ -11,6 +11,8 @@ import {
runAttioUserSync,
runAttioWorkspaceSync,
} from "~/services/attio.server";
+import { sweepDashboardAgentTurnEvals } from "~/services/dashboardAgentEvalRetention.server";
+import { sweepDashboardAgentInvestigations } from "~/services/dashboardAgentInvestigationSweep.server";
import { logger } from "~/services/logger.server";
import {
MembershipDevEnvironmentsSchema,
@@ -146,6 +148,16 @@ function initializeWorker() {
maxAttempts: 5,
},
},
+ // Stuck investigation cards and turn-eval retention.
+ "dashboardAgent.maintenance": {
+ schema: CronSchema,
+ visibilityTimeoutMs: 60_000 * 5,
+ cron: "*/5 * * * *",
+ jitterInMs: 30_000,
+ retry: {
+ maxAttempts: 1,
+ },
+ },
},
concurrency: {
workers: env.COMMON_WORKER_CONCURRENCY_WORKERS,
@@ -204,6 +216,31 @@ function initializeWorker() {
const service = new BulkActionService();
await service.process(payload.bulkActionId);
},
+ "dashboardAgent.maintenance": async () => {
+ // Each backstop runs independently; the first failure is rethrown at the end.
+ let failure: unknown;
+
+ try {
+ const investigations = await sweepDashboardAgentInvestigations();
+ if (investigations.stale > 0) {
+ logger.debug("Dashboard agent investigation sweep", investigations);
+ }
+ } catch (error) {
+ failure ??= error;
+ }
+
+ // Retention on the judged-turn rows. Independent of the agent being configured.
+ try {
+ const evals = await sweepDashboardAgentTurnEvals();
+ if (evals.purged > 0) {
+ logger.debug("Dashboard agent turn-eval retention", evals);
+ }
+ } catch (error) {
+ failure ??= error;
+ }
+
+ if (failure) throw failure;
+ },
},
});
diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts
index 9370c4023db..94e25408b4d 100644
--- a/apps/webapp/app/v3/featureFlags.ts
+++ b/apps/webapp/app/v3/featureFlags.ts
@@ -7,6 +7,8 @@ export const FEATURE_FLAG = {
hasLogsPageAccess: "hasLogsPageAccess",
hasAiAccess: "hasAiAccess",
hasDashboardAgentAccess: "hasDashboardAgentAccess",
+ dashboardAgentTurnEvalsEnabled: "dashboardAgentTurnEvalsEnabled",
+ promotedDashboardAgentPrompt: "promotedDashboardAgentPrompt",
hasComputeAccess: "hasComputeAccess",
hasPrivateConnections: "hasPrivateConnections",
hasSso: "hasSso",
@@ -43,6 +45,15 @@ export const FeatureFlagCatalog = {
// Gates the in-dashboard AI agent panel. Controllable globally and per-org
// (org wins). Defaults off via DASHBOARD_AGENT_ENABLED.
[FEATURE_FLAG.hasDashboardAgentAccess]: z.coerce.boolean(),
+ // Whether this org's agent turns may be sampled for the quality judge. A data-handling
+ // switch, not an entitlement: an org that turns it off has its turns judged never, and a
+ // setting that can't be read is treated as off. Per-org override wins; on by default.
+ // Strict z.boolean(): coercion reads the string "false" as true, which would keep judging
+ // an org that asked us to stop.
+ [FEATURE_FLAG.dashboardAgentTurnEvalsEnabled]: z.boolean(),
+ // A JSON string because this catalog is scalar-only. Validated where it's read, in
+ // `suggested-prompts/promotedPrompt.server.ts`.
+ [FEATURE_FLAG.promotedDashboardAgentPrompt]: z.string(),
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
[FEATURE_FLAG.hasSso]: z.coerce.boolean(),
diff --git a/apps/webapp/app/v3/queryScope.ts b/apps/webapp/app/v3/queryScope.ts
new file mode 100644
index 00000000000..143fb9bbd9f
--- /dev/null
+++ b/apps/webapp/app/v3/queryScope.ts
@@ -0,0 +1,42 @@
+import type { QueryScope } from "~/v3/querySchemas";
+
+/**
+ * The widest scope a credential may query at.
+ *
+ * `executeQuery` always isolates by organization and widens or narrows from there
+ * on the caller's `scope`. That makes the request body, not the credential, the
+ * ceiling โ which is wrong for a public access token: it is minted for one
+ * environment and handed to a browser, so anyone holding it could read the whole
+ * organization's analytics by changing one field.
+ *
+ * A secret key is deliberately NOT capped here. It is a server-side credential the
+ * organization's own owner installs, and capping it would change the public API's
+ * behaviour for callers who query at organization scope today. A session or PAT
+ * caller (the Query page) never goes through this: it picks its scope in the UI,
+ * authorized by organization membership.
+ */
+export type QueryScopeCeiling = "environment" | "unbounded";
+
+export type QueryScopeDecision = { ok: true; scope: QueryScope } | { ok: false; error: string };
+
+/**
+ * Rejected rather than narrowed. Silently answering about one environment when the
+ * caller asked about the organization gives them a number that means something else,
+ * with nothing in the response to say so.
+ */
+export function resolveQueryScope(args: {
+ ceiling: QueryScopeCeiling;
+ requested: QueryScope;
+}): QueryScopeDecision {
+ if (args.ceiling === "unbounded") return { ok: true, scope: args.requested };
+ if (args.requested === "environment") return { ok: true, scope: "environment" };
+ return {
+ ok: false,
+ error: `This token is scoped to one environment, so it can't run a ${args.requested}-scoped query. Use scope "environment", or a secret key.`,
+ };
+}
+
+/** A public access token is environment-bound; every other bearer credential isn't. */
+export function queryScopeCeilingFor(authenticationType: string): QueryScopeCeiling {
+ return authenticationType === "PUBLIC_JWT" ? "environment" : "unbounded";
+}
diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts
index 070be65f7ad..20d7c02333a 100644
--- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts
+++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts
@@ -392,6 +392,7 @@ export class DeliverAlertService extends BaseService {
break;
}
case "ERROR_GROUP": {
+ // Payload-carried alert types create no ProjectAlert row, so never seen here.
break;
}
default: {
@@ -747,6 +748,7 @@ export class DeliverAlertService extends BaseService {
break;
}
case "ERROR_GROUP": {
+ // Payload-carried alert types create no ProjectAlert row, so never seen here.
break;
}
default: {
@@ -1023,6 +1025,7 @@ export class DeliverAlertService extends BaseService {
}
}
case "ERROR_GROUP": {
+ // Payload-carried alert types create no ProjectAlert row, so never seen here.
break;
}
default: {
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index 79ac530f3d0..192f67dc455 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -5,7 +5,7 @@
"sideEffects": false,
"scripts": {
"build": "run-s build:** && pnpm run upload:sourcemaps",
- "build:remix": "remix vite:build",
+ "build:remix": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" remix vite:build",
"build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap",
"build:otlpworker": "esbuild --platform=node --format=cjs --bundle ./app/v3/otlpTransformWorker.ts --outfile=build/otlpTransformWorker.cjs --sourcemap",
"build:sentry": "esbuild --platform=node --format=cjs --outbase=. ./sentry.server.ts ./app/utils/sentryTraceContext.server.ts --outdir=build --sourcemap",
@@ -54,6 +54,7 @@
"@internal/cache": "workspace:*",
"@internal/compute": "workspace:*",
"@internal/dashboard-agent": "workspace:*",
+ "@internal/dashboard-agent-contracts": "workspace:*",
"@internal/dashboard-agent-db": "workspace:*",
"@internal/llm-model-catalog": "workspace:*",
"@internal/metrics-pipeline": "workspace:*",
diff --git a/apps/webapp/seed-queue-metrics.mts b/apps/webapp/seed-queue-metrics.mts
index 911ce51d9c6..f5f4d340dda 100644
--- a/apps/webapp/seed-queue-metrics.mts
+++ b/apps/webapp/seed-queue-metrics.mts
@@ -1,13 +1,17 @@
-import { prisma } from "./app/db.server";
-import { createOrganization } from "./app/models/organization.server";
-import { createProject } from "./app/models/project.server";
import { ClickHouse } from "@internal/clickhouse";
import type { QueueMetricsRawV1Input } from "@internal/clickhouse";
-import { generateFriendlyId } from "./app/v3/friendlyIdentifiers";
+// App modules compile to CommonJS under tsx, so import them as default bindings.
+import dbServer from "./app/db.server";
+import organizationServer from "./app/models/organization.server";
+import projectServer from "./app/models/project.server";
+import friendlyIdentifiers from "./app/v3/friendlyIdentifiers";
-// Queue metrics simulator: writes realistic raw rows into a synthetic tenant's
-// queue_metrics_raw_v1 and lets the MV build queue_metrics_v1 (the same path the real
-// consumer uses), so the dashboard can be built without the run engine. See TRI-10407.
+const { prisma } = dbServer;
+const { createOrganization } = organizationServer;
+const { createProject } = projectServer;
+const { generateFriendlyId } = friendlyIdentifiers;
+
+// Writes raw rows into queue_metrics_raw_v1 and lets the MVs build the rollups. See TRI-10407.
const ORG_TITLE = "Queue Metrics Dev";
const PROJECT_NAME = "queue-metrics-demo";
@@ -16,10 +20,9 @@ type Rng = () => number;
type QueueProfile = {
name: string;
limit: (bucket: number) => number;
- arrivals: (bucket: number, rng: Rng) => number; // expected new runs enqueued this bucket
+ arrivals: (bucket: number, rng: Rng) => number;
waitBaseMs: number;
- sparse?: boolean; // emit no rows when the queue is fully idle (tests carry-forward gaps)
- // Concurrency-key queue: adds CK-health gauge fields + live ckIndex staging (--usage)
+ sparse?: boolean;
ck?: {
backlogged: (bucket: number, rng: Rng) => number;
maxWaitMs: (bucket: number, rng: Rng) => number;
@@ -31,10 +34,6 @@ type Scenario = {
queues: QueueProfile[];
};
-// ---------------------------------------------------------------------------
-// CLI args
-// ---------------------------------------------------------------------------
-
function parseArgs(argv: string[]) {
const flags: Record = {};
for (let i = 0; i < argv.length; i++) {
@@ -59,10 +58,6 @@ function parseDuration(s: string): number {
return n * { s: 1, m: 60, h: 3600, d: 86400 }[unit]!;
}
-// ---------------------------------------------------------------------------
-// Deterministic RNG + distributions
-// ---------------------------------------------------------------------------
-
function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return () => {
@@ -103,17 +98,12 @@ function formatChDateTime(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
-// ---------------------------------------------------------------------------
-// Scenarios
-// ---------------------------------------------------------------------------
-
const steady = (): QueueProfile[] => [
{ name: "emails", limit: () => 20, arrivals: (_b, r) => poisson(12, r), waitBaseMs: 40 },
{ name: "webhooks", limit: () => 15, arrivals: (_b, r) => poisson(9, r), waitBaseMs: 40 },
{ name: "reports", limit: () => 10, arrivals: (_b, r) => poisson(5, r), waitBaseMs: 60 },
];
-// periodic bursts every ~30 buckets
const bursty = (name: string, limit: number, base: number): QueueProfile => ({
name,
limit: () => limit,
@@ -134,7 +124,6 @@ const scenarios: Record Sce
queues: [bursty("ingest", 20, 6), bursty("transform", 20, 7)],
}),
- // Tela case: sum of per-queue limits far exceeds the env limit, so queues compete.
"over-allocated-env": () => ({
description: "Sum(queue limits)=120 >> env limit=40; env saturates, queues env-limited",
envLimit: () => 40,
@@ -190,8 +179,6 @@ const scenarios: Record Sce
],
}),
- // Pagination + relevance-ranking design surface: one runaway queue, a busy-but-healthy
- // head, a bursty middle, and a long sparse tail across 61 queues (the list pages at 25).
"many-queues": () => ({
description:
"61 queues: one runaway, busy head, bursty middle, long sparse tail (pagination + ranking)",
@@ -223,9 +210,6 @@ const scenarios: Record Sce
],
}),
- // Per-tenant concurrency keys: a hog tenant periodically floods the queue and starves
- // the others, so the CK charts (keys with backlog, most-starved wait) and the live
- // per-key table on the queue detail page have something to show. Use with --usage.
"tenant-hotspot": () => ({
description:
"CK queue where a hog tenant starves others: CK charts + live key table (use --usage)",
@@ -248,10 +232,9 @@ const scenarios: Record Sce
],
}),
- // Default: one env with a variety of queue behaviours + occasional env saturation.
mixed: (totalBuckets) => ({
description: "variety of queue profiles in one env, with occasional env saturation",
- envLimit: (b) => (b % 40 < 12 ? 45 : 70), // dips low periodically to flip env saturation
+ envLimit: (b) => (b % 40 < 12 ? 45 : 70),
queues: [
{ name: "emails", limit: () => 20, arrivals: (_b, r) => poisson(12, r), waitBaseMs: 40 },
bursty("webhooks", 20, 6),
@@ -273,18 +256,13 @@ const scenarios: Record Sce
}),
};
-// ---------------------------------------------------------------------------
-// Simulation
-// ---------------------------------------------------------------------------
-
type Ids = { organization_id: string; project_id: string; environment_id: string };
const WAIT_SIGMA = 0.6;
const NACK_RATE = 0.02;
const DLQ_RATE = 0.004;
type CounterOp = "enqueue" | "started" | "ack" | "nack" | "dlq";
-// Per-(queue, op) odometers, mirroring the production emitter: cumulative readings with a
-// cum=0 baseline on the first one, so deltaSumTimestamp captures the 0->1 delta.
+// Cumulative odometers: the first reading must be cum=0 so deltaSumTimestamp sees the 0->1 delta.
type CounterState = Record[];
function counterRows(
@@ -325,8 +303,7 @@ function newCounterState(n: number): CounterState {
return Array.from({ length: n }, () => ({ enqueue: 0, started: 0, ack: 0, nack: 0, dlq: 0 }));
}
-// Per-key simulation for CK profiles: 12 tenants (tenant-01 is the hog, matching
-// stageRedisUsage), per-tenant backlog drained round-robin, per-tenant odometers.
+// tenant-01 is the hog here and in stageRedisUsage; keep the two in step.
const CK_TENANT_COUNT = 12;
type CkSimState = { backlog: number[]; counters: Map> };
const ckSim = new Map();
@@ -369,9 +346,7 @@ function ckCounterRows(
return rows;
}
-// Advance one bucket of the simulation for every queue, returning the raw rows to insert.
-// `backlog` and `counters` are mutated in place so state carries across buckets (and into
-// live mode).
+// `backlog` and `counters` are mutated in place: state carries across buckets and into live mode.
function simulateBucket(
scenario: Scenario,
bucket: number,
@@ -391,12 +366,11 @@ function simulateBucket(
for (let q = 0; q < n; q++) {
limit[q] = scenario.queues[q].limit(bucket);
const arrivals = Math.min(500, scenario.queues[q].arrivals(bucket, rng));
- const prior = backlog[q]; // backlog carried from earlier buckets, before this bucket's arrivals
- backlog[q] += arrivals; // arrivals join the backlog; recorded as enqueues below
+ const prior = backlog[q];
+ backlog[q] += arrivals;
(desired as any)[q] = { arrivals, prior, want: Math.min(limit[q], backlog[q]) };
}
- // Env cap: if the queues collectively want more concurrency than the env allows, scale down.
const sumWant = desired.reduce((s: number, d: any) => s + d.want, 0);
const scale = sumWant > envLimit && sumWant > 0 ? envLimit / sumWant : 1;
@@ -412,8 +386,7 @@ function simulateBucket(
envQueued += queued[q];
}
- // Order keys are time-based (like the production stream ids) so appended runs and live
- // mode stay monotonic; the per-bucket sequence keeps them unique within a bucket.
+ // Order keys must be monotonic across processes, so they are time-based plus a per-bucket seq.
let bucketSeq = 0;
const orderKey = () => bucketEpochSec * 1_000_000 + bucketSeq++;
@@ -422,14 +395,13 @@ function simulateBucket(
const profile = scenario.queues[q];
const started = running[q];
const arrivals = (desired[q] as any).arrivals as number;
- const prior = (desired[q] as any).prior as number; // depth a starting run actually queued behind
- backlog[q] = queued[q]; // carry the unserved remainder forward
+ const prior = (desired[q] as any).prior as number;
+ backlog[q] = queued[q];
if (profile.sparse && arrivals === 0 && started === 0 && prior === 0) {
- continue; // fully idle: leave a gap so carry-forward is exercised
+ continue;
}
- // CK-health fields stay coherent with the depth: no queued runs means no backlogged keys.
const ckBacklogged = profile.ck
? queued[q] > 0
? Math.max(1, Math.min(profile.ck.backlogged(bucket, rng), queued[q]))
@@ -460,8 +432,6 @@ function simulateBucket(
rows.push(...counterRows(counters, q, ids, profile.name, eventTime, orderKey, "enqueue"));
}
- // Per-key rows for CK profiles: assign arrivals hog-weighted, drain round-robin
- // (fair share), then emit per-tenant odometers + a per-key gauge per active tenant.
if (profile.ck) {
let ckq = ckSim.get(q);
if (!ckq) {
@@ -543,10 +513,6 @@ function simulateBucket(
return rows;
}
-// ---------------------------------------------------------------------------
-// ClickHouse
-// ---------------------------------------------------------------------------
-
function clickhouse(): ClickHouse {
const clickhouseUrl = process.env.CLICKHOUSE_URL ?? process.env.EVENTS_CLICKHOUSE_URL;
if (!clickhouseUrl) {
@@ -554,7 +520,7 @@ function clickhouse(): ClickHouse {
process.exit(1);
}
const url = new URL(clickhouseUrl);
- // Allowlist local hosts only (this script TRUNCATEs), and never echo the URL (it carries creds).
+ // Local hosts only (this script deletes rows); never echo the URL, it carries credentials.
const localHosts = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
if (!localHosts.has(url.hostname)) {
console.error(`Refusing to run against a non-local ClickHouse host: ${url.hostname}`);
@@ -596,7 +562,6 @@ async function resetEnv(ch: ClickHouse, environmentId: string) {
console.log(`Reset queue metrics for environment ${environmentId}`);
}
-// Fake running counts in the run-queue Redis (Running column + allocation usage bars).
// Reconciled every run: staged with --usage, cleared otherwise.
async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear: boolean) {
const host = process.env.RUN_ENGINE_RUN_QUEUE_REDIS_HOST ?? process.env.REDIS_HOST ?? "localhost";
@@ -616,16 +581,11 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear
const logicalBase = `{org:${ids.organization_id}}:proj:${ids.project_id}:env:${ids.environment_id}:queue:`;
const base = `${prefix}${logicalBase}`;
- // Env-level structures the Queues list "Queued"/"Running" blocks read:
- // lengthOfEnvQueue -> ZCARD(envQueueKey) (ZSET, no proj section)
- // concurrencyOfEnvQueue -> SCARD(envCurrentDequeuedKey) (SET)
- // We accumulate the per-queue staged counts below and stage these so the blocks
- // equal the table's per-queue sums instead of showing 0/0.
+ // envQueue is a ZSET with no proj section; envCurrentDequeued is a SET with one.
const envQueueKey = `${prefix}{org:${ids.organization_id}}:env:${ids.environment_id}`;
const envCurrentDequeuedKey = `${prefix}{org:${ids.organization_id}}:proj:${ids.project_id}:env:${ids.environment_id}:currentDequeued`;
await redis.del(envQueueKey, envCurrentDequeuedKey);
- // Table Queued = ZCARD(base) + lengthCounter (base zset unstaged -> only CK queues
- // contribute their lengthCounter). Table Running = SCARD(currentDequeued) per queue.
+ // Per-queue Queued = ZCARD(base) + lengthCounter; Running = SCARD(currentDequeued).
let envQueuedTotal = 0;
let envRunningTotal = 0;
@@ -633,8 +593,7 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear
const key = `${base}${profile.name}:currentDequeued`;
await redis.del(key);
- // CK staging (ckIndex + per-key subqueues) feeds the live per-key table on the queue
- // detail page. Members are stored unprefixed, exactly like the run-queue Lua does.
+ // ckIndex members are stored unprefixed, exactly like the run-queue Lua does.
const ckIndexKey = `${base}${profile.name}:ckIndex`;
const lengthCounterKey = `${base}${profile.name}:lengthCounter`;
const staleMembers = await redis.zrange(ckIndexKey, 0, -1);
@@ -645,7 +604,6 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear
if (clear) continue;
const limit = profile.limit(0);
- // First queue rides at/over its limit, the rest at 30-90%, sparse mostly idle.
const count = profile.sparse
? rng() < 0.3
? 1
@@ -683,15 +641,12 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear
await redis.zadd(ckIndexKey, now - oldestAgeMs, member);
totalCkQueued += queuedCount;
}
- // The aggregate "Queued now" reads ZCARD(base) + this counter; keep them coherent.
await redis.set(lengthCounterKey, totalCkQueued, "EX", 24 * 3600);
envQueuedTotal += totalCkQueued;
}
}
- // Stage the env-level structures so the list-page blocks match the table sums.
- // Members are unique across queues (each per-queue set uses its own key, but the
- // env set/zset needs distinct members to reach the summed cardinality).
+ // The env set/zset needs members distinct across queues to reach the summed cardinality.
if (!clear) {
if (envRunningTotal > 0) {
await redis.sadd(
@@ -718,13 +673,8 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear
}
}
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
-
-// Make the synthetic project a V2 engine project with a current dev worker + a Postgres
-// TaskQueue per simulated queue, so the /queues list renders the V2 table (it pages from
-// Postgres and gates on engine version; ClickHouse only holds the metrics).
+// The /queues list pages from Postgres and gates on engine version, so the project needs
+// engine V2, a worker, and a TaskQueue row per simulated queue.
async function ensureTaskQueues(
scenario: Scenario,
projectId: string,
@@ -768,9 +718,7 @@ async function ensureTaskQueues(
projectId,
type: "NAMED",
},
- // Reset any dashboard override left from manual testing: re-seeding overwrites the
- // materialized concurrencyLimit, so a surviving override percent/base would contradict it
- // (e.g. "10 (77%)" with an env limit of 25).
+ // Re-seeding overwrites concurrencyLimit, so a surviving override would contradict it.
update: {
concurrencyLimit,
concurrencyLimitBase: null,
@@ -781,8 +729,6 @@ async function ensureTaskQueues(
});
}
- // Drop queues left over from a previously seeded scenario so switching scenarios
- // does not leave metric-less rows in the list.
const { count: pruned } = await prisma.taskQueue.deleteMany({
where: {
runtimeEnvironmentId,
@@ -916,7 +862,6 @@ async function main() {
`Backfilling ${totalBuckets} x ${bucketSec}s buckets (${flags.window ?? "2h"}) for ${scenario.queues.length} queues...`
);
- // Backfill: buckets from (now - window) up to now, aligned to the bucket grid.
const nowBucket = Math.floor(Date.now() / 1000 / bucketSec) * bucketSec;
const startBucket = nowBucket - totalBuckets * bucketSec;
const counters = newCounterState(scenario.queues.length);
@@ -941,8 +886,7 @@ async function main() {
await insertBatched(ch, rows, nonce);
console.log(`Inserted ${rows.length} raw rows.`);
- // Merge the AggregatingMergeTree partials so argMax "current value" widgets read cleanly.
- // The real pipeline relies on background merges; the simulator forces it for a tidy demo.
+ // The rollups are AggregatingMergeTrees; a read straight after the insert can't wait for merges.
const raw = (
ch.writer as unknown as { client: { command: (a: { query: string }) => Promise } }
).client;
diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts
index a5dbfb06b2a..9b55af4357c 100644
--- a/apps/webapp/server.ts
+++ b/apps/webapp/server.ts
@@ -187,6 +187,7 @@ async function startServer() {
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:
@@ -240,6 +241,10 @@ async function startServer() {
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
diff --git a/apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap b/apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap
new file mode 100644
index 00000000000..288c010dc34
--- /dev/null
+++ b/apps/webapp/test/__snapshots__/reportRenderParity.test.ts.snap
@@ -0,0 +1,161 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ANSI surface > paints the same layout (degraded) 1`] = `
+"/report health prod ยท last 1h ยท vs your 7d normal>
+
+โ> Flow stalled โ at your env concurrency limit for the last 38 min>
+
+ concurrency 50/50 โ>โ>โ>โ>โ
>โ>โ>โ> 38 min at limit
+
+ pending 4,812 โ 120ร> โ>โ>โ>โ>โ
>โ>โ>โ> (normal ~40)>
+
+ start latency p95 43s โ 6ร> โ>โ>โ>โ>โ
>โ>โ>โ> (normal ~7s)>
+
+ throughput โ180/min
+ done 820/min
+ triggered 1,000/min
+
+ why: 71% of pending is demo-email-sends>
+ not your code โ failures and durations normal>
+ runs are finishing at ~820/min>
+
+โ> EXECUTION the runs that DO start are fine
+
+โ> LIVENESS fresh โ telemetry current, updated 18s ago
+
+ read: limit saturated โ incoming work exceeds capacity โ backlog grows>
+ NOT a code problem>
+
+โ Contact us to raise the limit
+ Read concurrency docs
+ or do nothing โ backlog drains in ~26.7 min once triggers ease"
+`;
+
+exports[`ANSI surface > paints the same layout (empty) 1`] = `
+"/report health prod ยท last 1h>
+
+โ> health ok
+
+โ> data fresh
+
+โ nothing to do"
+`;
+
+exports[`ANSI surface > paints the same layout (healthy) 1`] = `
+"/report health prod ยท last 1h ยท vs your 7d normal>
+
+โ> Flow healthy โ starting normally
+
+ start latency p95 6.8s โ flat> โ>โ>โ>โ
>โ>โ>โ>โ> (normal ~7s)>
+
+ pending 34 โ flat> โ>โ>โ>โ
>โ>โ>โ>โ> (normal ~40)>
+
+ throughput +12/min
+ done 842/min
+ triggered 830/min
+
+โ> EXECUTION the runs that DO start are fine
+
+โ> LIVENESS fresh โ telemetry current, updated 21s ago
+
+ read: runs are starting on time>
+ runs are completing normally>
+
+โ nothing to do"
+`;
+
+exports[`ANSI surface > paints the same layout (untrustworthy) 1`] = `
+"/report health โ> stale data prod ยท last 1h ยท vs your 7d normal>
+
+โ> Flow unknown โ data stale
+
+โ> The telemetry behind this report is stale, so the numbers below are informational only.
+
+โ> EXECUTION execution can't be assessed โ the telemetry is stale
+
+โ> LIVENESS stale โ no telemetry in 21m
+
+ liveness 21m
+
+โ Check control plane"
+`;
+
+exports[`markdown surface > renders (degraded) 1`] = `
+"/report health prod ยท last 1h ยท vs your 7d normal
+
+๐ด Flow stalled โ at your env concurrency limit for the last 38 min
+
+ concurrency 50/50 โโโโโ
โโโ 38 min at limit
+
+ pending 4,812 โ 120ร โโโโโ
โโโ (normal ~40)
+
+ start latency p95 43s โ 6ร โโโโโ
โโโ (normal ~7s)
+
+ throughput โ180/min
+ done 820/min
+ triggered 1,000/min
+
+ why: 71% of pending is demo-email-sends
+ not your code โ failures and durations normal
+ runs are finishing at ~820/min
+
+๐ข EXECUTION the runs that DO start are fine
+
+๐ข LIVENESS fresh โ telemetry current, updated 18s ago
+
+ read: limit saturated โ incoming work exceeds capacity โ backlog grows
+ NOT a code problem
+
+โ Contact us to raise the limit
+ Read concurrency docs
+ or do nothing โ backlog drains in ~26.7 min once triggers ease"
+`;
+
+exports[`markdown surface > renders (empty) 1`] = `
+"/report health prod ยท last 1h
+
+๐ข health ok
+
+๐ข data fresh
+
+โ nothing to do"
+`;
+
+exports[`markdown surface > renders (healthy) 1`] = `
+"/report health prod ยท last 1h ยท vs your 7d normal
+
+๐ข Flow healthy โ starting normally
+
+ start latency p95 6.8s โ flat โโโโ
โโโโ (normal ~7s)
+
+ pending 34 โ flat โโโโ
โโโโ (normal ~40)
+
+ throughput +12/min
+ done 842/min
+ triggered 830/min
+
+๐ข EXECUTION the runs that DO start are fine
+
+๐ข LIVENESS fresh โ telemetry current, updated 21s ago
+
+ read: runs are starting on time
+ runs are completing normally
+
+โ nothing to do"
+`;
+
+exports[`markdown surface > renders (untrustworthy) 1`] = `
+"/report health ๐ฉ stale data prod ยท last 1h ยท vs your 7d normal
+
+๐ด Flow unknown โ data stale
+
+๐ฉ The telemetry behind this report is stale, so the numbers below are informational only.
+
+๐ด EXECUTION execution can't be assessed โ the telemetry is stale
+
+๐ด LIVENESS stale โ no telemetry in 21m
+
+ liveness 21m
+
+โ Check control plane"
+`;
diff --git a/apps/webapp/test/apiAuthActorClaim.test.ts b/apps/webapp/test/apiAuthActorClaim.test.ts
new file mode 100644
index 00000000000..0ad2f6ab1f2
--- /dev/null
+++ b/apps/webapp/test/apiAuthActorClaim.test.ts
@@ -0,0 +1,109 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { buildJwtAbility } from "@trigger.dev/rbac";
+
+const jwtMocks = vi.hoisted(() => ({
+ validatePublicJwtKey: vi.fn<(...args: any[]) => Promise>(),
+}));
+
+vi.mock("@internal/tracing", () => ({
+ getMeter: () => ({
+ createCounter: () => ({ add: vi.fn() }),
+ createHistogram: () => ({ record: vi.fn() }),
+ createObservableGauge: () => ({ addCallback: vi.fn() }),
+ }),
+}));
+vi.mock("~/services/rbac.server", () => ({ rbac: { authenticateBearer: vi.fn() } }));
+vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
+vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
+vi.mock("~/models/project.server", () => ({ findProjectByRef: vi.fn() }));
+vi.mock("~/models/runtimeEnvironment.server", () => ({
+ authIncludeBase: {},
+ authIncludeWithParent: {},
+ findEnvironmentByApiKey: vi.fn(),
+ findEnvironmentByApiKeyWithResolution: vi.fn(),
+ findEnvironmentByPublicApiKey: vi.fn(),
+ toAuthenticated: vi.fn(),
+}));
+vi.mock("~/services/personalAccessToken.server", () => ({
+ authenticateApiRequestWithPersonalAccessToken: vi.fn(),
+ isPersonalAccessToken: () => false,
+}));
+vi.mock("~/services/organizationAccessToken.server", () => ({
+ authenticateApiRequestWithOrganizationAccessToken: vi.fn(),
+ isOrganizationAccessToken: () => false,
+}));
+vi.mock("~/services/realtime/jwtAuth.server", () => ({
+ isPublicJWT: (token: string) => token.startsWith("jwt_"),
+ validatePublicJwtKey: jwtMocks.validatePublicJwtKey,
+}));
+vi.mock("~/services/logger.server", () => ({
+ logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
+}));
+
+import { authenticateApiKey } from "~/services/apiAuth.server";
+
+const environment = { id: "env_1", apiKey: "tr_prod_abc" };
+
+function claims(extra: Record = {}) {
+ return { sub: environment.id, pub: true, scopes: ["read:runs"], ...extra };
+}
+
+async function authenticate(jwtClaims: Record) {
+ jwtMocks.validatePublicJwtKey.mockResolvedValue({
+ ok: true,
+ environment,
+ claims: jwtClaims,
+ });
+ const result = await authenticateApiKey("jwt_token", { allowJWT: true });
+ if (!result) throw new Error("expected authentication to succeed");
+ return result;
+}
+
+describe("PUBLIC_JWT authentication โ actor claim", () => {
+ beforeEach(() => {
+ jwtMocks.validatePublicJwtKey.mockReset();
+ });
+
+ it("surfaces actor when the JWT carries act", async () => {
+ const result = await authenticate(
+ claims({ act: { sub: "usr_42", client: "dashboard-agent" } })
+ );
+
+ expect(result.actor).toEqual({ sub: "usr_42", client: "dashboard-agent" });
+ });
+
+ it("accepts act without a client", async () => {
+ const result = await authenticate(claims({ act: { sub: "usr_42" } }));
+
+ expect(result.actor).toEqual({ sub: "usr_42" });
+ });
+
+ it("leaves actor undefined when the JWT has no act", async () => {
+ const result = await authenticate(claims());
+
+ expect(result.actor).toBeUndefined();
+ });
+
+ it("ignores a malformed act rather than failing the request", async () => {
+ const result = await authenticate(claims({ act: { client: "dashboard-agent" } }));
+
+ expect(result.ok).toBe(true);
+ expect(result.actor).toBeUndefined();
+ });
+
+ it("does not let act widen authorization", async () => {
+ // The act claim is identity data: authorization comes from sub + scopes only.
+ const forged = claims({
+ act: { sub: "usr_42", client: "dashboard-agent", scopes: ["admin"] },
+ });
+ const result = await authenticate(forged);
+
+ expect(result.environment.id).toBe(environment.id);
+ expect(result.actor).toEqual({ sub: "usr_42", client: "dashboard-agent" });
+
+ const ability = buildJwtAbility(forged.scopes);
+ expect(ability.rules).toEqual(buildJwtAbility(["read:runs"]).rules);
+ expect(ability.can("read", { type: "runs" })).toBe(true);
+ expect(ability.can("write", { type: "runs" })).toBe(false);
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentBodyCap.test.ts b/apps/webapp/test/dashboardAgentBodyCap.test.ts
new file mode 100644
index 00000000000..599b15df79b
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentBodyCap.test.ts
@@ -0,0 +1,151 @@
+import express from "express";
+import type { Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import { Readable } from "node:stream";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ DASHBOARD_AGENT_MAX_INGRESS_BYTES,
+ dashboardAgentBodyCap,
+} from "~/services/dashboardAgentBodyCap.server";
+
+// The cap has to hold for a body with no `content-length`: that is the case a route-level
+// check can't cover, because by then the body is already in memory.
+
+let server: Server | undefined;
+
+/** A server whose route stands in for Remix: it reads the whole body, like `text()` would. */
+async function listen(): Promise<{ url: string; buffered: () => number }> {
+ let buffered = 0;
+ const app = express();
+ app.use(dashboardAgentBodyCap);
+ app.all("*", async (req, res) => {
+ try {
+ for await (const chunk of req) buffered += (chunk as Buffer).byteLength;
+ } catch {
+ // The cap tore the request down; that is the point.
+ return;
+ }
+ res.status(200).json({ bytes: buffered });
+ });
+
+ server = app.listen(0);
+ await new Promise((resolve) => server!.once("listening", resolve));
+ return {
+ url: `http://127.0.0.1:${(server!.address() as AddressInfo).port}`,
+ buffered: () => buffered,
+ };
+}
+
+/** A chunked POST: `fetch` omits `content-length` for a stream body. */
+function postChunked(url: string, totalBytes: number, chunkBytes = 16 * 1024) {
+ let left = totalBytes;
+ const body = new Readable({
+ read() {
+ if (left <= 0) {
+ this.push(null);
+ return;
+ }
+ const size = Math.min(chunkBytes, left);
+ left -= size;
+ this.push(Buffer.alloc(size, "a"));
+ },
+ });
+
+ return fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: Readable.toWeb(body) as ReadableStream,
+ // @ts-expect-error โ undici needs this for a streamed request body.
+ duplex: "half",
+ });
+}
+
+afterEach(async () => {
+ await new Promise((resolve) => (server ? server.close(resolve) : resolve(undefined)));
+ server = undefined;
+});
+
+describe("the dashboard agent's ingress cap", () => {
+ it("refuses a chunked oversized body without buffering it", async () => {
+ const { url, buffered } = await listen();
+ const oversized = DASHBOARD_AGENT_MAX_INGRESS_BYTES * 20;
+
+ // A client still uploading may see the reset rather than read the 413; either way the
+ // request is over long before the body is.
+ const response = await postChunked(`${url}/env/dev/dashboard-agent/in/append`, oversized).catch(
+ () => undefined
+ );
+
+ if (response) expect(response.status).toBe(413);
+ expect(buffered()).toBeLessThan(oversized / 2);
+ });
+
+ it("answers 413 for a chunked body a little over the cap", async () => {
+ const { url } = await listen();
+
+ const response = await postChunked(
+ `${url}/env/dev/dashboard-agent/in/append`,
+ DASHBOARD_AGENT_MAX_INGRESS_BYTES + 32 * 1024
+ );
+
+ expect(response.status).toBe(413);
+ expect(await response.json()).toMatchObject({ code: "message_too_large" });
+ });
+
+ it("refuses a declared oversized body before reading anything", async () => {
+ const { url, buffered } = await listen();
+ const response = await fetch(`${url}/env/dev/dashboard-agent`, {
+ method: "POST",
+ headers: { "content-type": "text/plain" },
+ body: "x".repeat(DASHBOARD_AGENT_MAX_INGRESS_BYTES + 1),
+ });
+
+ expect(response.status).toBe(413);
+ expect(buffered()).toBe(0);
+ });
+
+ it("passes a body under the cap through untouched", async () => {
+ const { url } = await listen();
+ const size = 32 * 1024;
+
+ const response = await postChunked(`${url}/env/dev/dashboard-agent`, size);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ bytes: size });
+ });
+
+ it("caps a mixed-case path, because Remix matches routes case-insensitively", async () => {
+ const { url } = await listen();
+
+ const response = await postChunked(
+ `${url}/api/v1/Dashboard-Agent/eval-policy`,
+ DASHBOARD_AGENT_MAX_INGRESS_BYTES * 4
+ );
+
+ expect(response.status).toBe(413);
+ });
+
+ it("caps a DELETE, which can read a body too", async () => {
+ const { url } = await listen();
+
+ const response = await fetch(`${url}/api/v1/dashboard-agent/eval-policy`, {
+ method: "DELETE",
+ body: "x".repeat(DASHBOARD_AGENT_MAX_INGRESS_BYTES + 1024),
+ });
+
+ expect(response.status).toBe(413);
+ });
+
+ it("leaves every other path alone", async () => {
+ const { url, buffered } = await listen();
+ const size = DASHBOARD_AGENT_MAX_INGRESS_BYTES + 1024;
+
+ const response = await fetch(`${url}/api/v1/artifacts`, {
+ method: "POST",
+ body: "x".repeat(size),
+ });
+
+ expect(response.status).toBe(200);
+ expect(buffered()).toBe(size);
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentClientMetadata.test.ts b/apps/webapp/test/dashboardAgentClientMetadata.test.ts
new file mode 100644
index 00000000000..95cdeb7d08d
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentClientMetadata.test.ts
@@ -0,0 +1,126 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ fetch: vi.fn(),
+}));
+
+vi.mock("~/db.server", () => ({ $replica: {} }));
+vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
+vi.mock("~/services/session.server", () => ({
+ requireUser: async () => ({ id: "usr_real", admin: false, isImpersonating: false }),
+}));
+vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
+ canAccessDashboardAgent: async () => true,
+}));
+vi.mock("~/models/project.server", () => ({
+ findProjectBySlug: async () => ({
+ id: "proj_real",
+ organizationId: "org_real",
+ externalRef: "proj_ref_real",
+ }),
+}));
+vi.mock("~/models/runtimeEnvironment.server", () => ({
+ findEnvironmentBySlug: async () => ({ id: "env_real", type: "DEVELOPMENT" }),
+}));
+vi.mock("~/services/dashboardAgent.server", () => ({
+ dashboardAgentApiOrigin: () => "https://api.trigger.dev",
+ dashboardAgentEnvironmentName: () => "dev",
+ mintDashboardAgentUserActorToken: async () => "tr_uat_real",
+ resolveDashboardAgentRepoSnapshot: async () => null,
+}));
+vi.mock("~/services/logger.server", () => ({
+ logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
+}));
+
+import { action } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$";
+
+async function appendTurn(metadata: Record): Promise> {
+ const request = new Request(
+ "https://app.trigger.dev/resources/orgs/acme/projects/api/env/dev/dashboard-agent/in/realtime/v1/sessions/chat_1/in/append",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ kind: "message",
+ payload: { metadata, message: { parts: [{ type: "text", text: "hi" }] } },
+ }),
+ }
+ );
+
+ const response = await action({
+ request,
+ params: {
+ organizationSlug: "acme",
+ projectParam: "api",
+ envParam: "dev",
+ "*": "realtime/v1/sessions/chat_1/in/append",
+ },
+ context: {},
+ } as any);
+
+ expect(response.status).toBe(200);
+ expect(mocks.fetch).toHaveBeenCalledTimes(1);
+ const forwarded = JSON.parse(mocks.fetch.mock.calls[0][1].body as string);
+ return forwarded.payload.metadata as Record;
+}
+
+describe("dashboard agent `in` proxy โ client metadata", () => {
+ beforeEach(() => {
+ mocks.fetch.mockReset();
+ mocks.fetch.mockResolvedValue(
+ new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })
+ );
+ vi.stubGlobal("fetch", mocks.fetch);
+ });
+
+ it("keeps the whitelisted page context", async () => {
+ const metadata = await appendTurn({
+ currentPage: "/orgs/acme/projects/api/env/dev/runs",
+ pageContext: { kind: "runs" },
+ });
+
+ expect(metadata.currentPage).toBe("/orgs/acme/projects/api/env/dev/runs");
+ expect(metadata.pageContext).toEqual({ kind: "runs" });
+ });
+
+ it("ignores a client-sent copy of every server-owned field", async () => {
+ const metadata = await appendTurn({
+ currentPage: "/runs",
+ organizationId: "org_evil",
+ userId: "usr_evil",
+ projectId: "proj_evil",
+ projectRef: "proj_ref_evil",
+ environmentId: "env_evil",
+ environmentName: "prod",
+ apiOrigin: "https://evil.example.com",
+ userActorToken: "tr_uat_evil",
+ repoSnapshot: { tarballUrl: "https://evil.example.com/x.tar.gz" },
+ });
+
+ expect(metadata.organizationId).toBe("org_real");
+ expect(metadata.userId).toBe("usr_real");
+ expect(metadata.projectId).toBe("proj_real");
+ expect(metadata.projectRef).toBe("proj_ref_real");
+ expect(metadata.environmentId).toBe("env_real");
+ expect(metadata.environmentName).toBe("dev");
+ expect(metadata.apiOrigin).toBe("https://api.trigger.dev");
+ expect(metadata.userActorToken).toBe("tr_uat_real");
+ expect(metadata.repoSnapshot).toBeUndefined();
+ });
+
+ it("drops any field the server doesn't own", async () => {
+ const metadata = await appendTurn({
+ currentPage: "/runs",
+ evalOptOut: false,
+ cap: ["admin"],
+ somethingNew: "smuggled",
+ });
+
+ expect(metadata).not.toHaveProperty("evalOptOut");
+ expect(metadata).not.toHaveProperty("cap");
+ expect(metadata).not.toHaveProperty("somethingNew");
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentDelegatedScopeCeiling.test.ts b/apps/webapp/test/dashboardAgentDelegatedScopeCeiling.test.ts
new file mode 100644
index 00000000000..c9e2e1c97db
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentDelegatedScopeCeiling.test.ts
@@ -0,0 +1,78 @@
+import { expect, it, vi } from "vitest";
+
+/**
+ * Self-hosted runs the RBAC fallback, where a PAT gets a blanket ability. A delegated token must
+ * not: the env JWT it exchanges for carries scopes with no role context, so the actor's own
+ * ability is the only ceiling left. Were the fallback permissive here, the agent's read-only cap
+ * would buy a write JWT.
+ */
+
+vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
+
+import plugin, { signUserActorToken } from "@trigger.dev/rbac";
+import type { PrismaClient } from "@trigger.dev/database";
+import { clampUserActorScopes } from "~/services/userActorEnvironment.server";
+
+const SECRET = "test-secret-for-delegated-scope-ceiling";
+const AGENT_CAP = ["read:apiKeys", "read:runs", "read:deployments"];
+
+function fallbackController() {
+ return plugin.create({ primary: {} as PrismaClient, replica: {} as PrismaClient }, {
+ forceFallback: true,
+ userActorSecret: SECRET,
+ } as any);
+}
+
+async function abilityFor(cap?: string[]) {
+ const token = await signUserActorToken(SECRET, {
+ userId: "usr_1",
+ client: "dashboard-agent",
+ environmentId: "env_1",
+ ...(cap ? { cap } : {}),
+ });
+ const result = await fallbackController().authenticateUserActor(
+ new Request("https://api.trigger.dev/api/v1/test", {
+ headers: { Authorization: `Bearer ${token}` },
+ }),
+ {}
+ );
+
+ if (!result.ok) throw new Error("the fallback rejected the token");
+ return { ability: result.ability, claims: { userId: "usr_1", client: "dashboard-agent", cap } };
+}
+
+it("refuses a write scope the agent's cap doesn't carry", async () => {
+ const { ability, claims } = await abilityFor(AGENT_CAP);
+
+ const clamped = clampUserActorScopes(["write:runs"], claims, ability);
+
+ expect(clamped.scopes).toEqual([]);
+ expect(clamped.deniedScopes).toContain("write:runs");
+});
+
+it("still hands over the reads the cap does carry", async () => {
+ const { ability, claims } = await abilityFor(AGENT_CAP);
+
+ const clamped = clampUserActorScopes(["read:runs"], claims, ability);
+
+ expect(clamped.scopes).toEqual(["read:runs"]);
+});
+
+it("refuses a write the cap forbids even when the user's role allows it", async () => {
+ // The cloud path builds the ability from the user's role, not from the token's cap โ
+ // so the role alone would hand a read-only agent token a write JWT.
+ const { claims } = await abilityFor(AGENT_CAP);
+ const roleAllowsEverything = { can: () => true, canSuper: () => false } as never;
+
+ const clamped = clampUserActorScopes(["write:runs"], claims, roleAllowsEverything);
+
+ expect(clamped.scopes).toEqual([]);
+ expect(clamped.deniedScopes).toContain("write:runs");
+});
+
+it("keeps a capless delegated token read-only", async () => {
+ const { ability, claims } = await abilityFor();
+
+ expect(clampUserActorScopes(["write:runs"], claims, ability).scopes).toEqual([]);
+ expect(clampUserActorScopes(undefined, claims, ability).scopes).toEqual(["read:all"]);
+});
diff --git a/apps/webapp/test/dashboardAgentEvalPolicyAuth.test.ts b/apps/webapp/test/dashboardAgentEvalPolicyAuth.test.ts
new file mode 100644
index 00000000000..2c4f0108e8a
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentEvalPolicyAuth.test.ts
@@ -0,0 +1,132 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The eval-policy gate names an organization in its query, so the caller's membership is the
+ * tenant floor. These drive the real route through the real preamble with real signed tokens;
+ * only the database is stubbed.
+ */
+
+const { SESSION_SECRET } = vi.hoisted(() => ({
+ SESSION_SECRET: "test-session-secret-for-eval-policy-auth",
+}));
+
+const mocks = vi.hoisted(() => ({
+ organizationFindFirst: vi.fn<(...args: any[]) => Promise>(),
+}));
+
+vi.mock("~/env.server", () => ({
+ env: { SESSION_SECRET, APP_ORIGIN: "https://example.com" },
+}));
+vi.mock("~/services/logger.server", () => ({
+ logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
+}));
+vi.mock("~/services/apiAuth.server", () => ({
+ authenticateRequest: async () => undefined,
+}));
+vi.mock("~/db.server", () => ({
+ prisma: {
+ organization: { findFirst: mocks.organizationFindFirst },
+ featureFlag: { findFirst: async () => null },
+ },
+ $replica: {},
+}));
+
+import { signUserActorToken } from "@trigger.dev/rbac";
+import { loader } from "~/routes/api.v1.dashboard-agent.eval-policy";
+
+const USER_ID = "usr_member";
+const MEMBER_ORG = "org_member";
+const OTHER_ORG = "org_other";
+const ENV_IN_MEMBER_ORG = "env_aaaa";
+
+function mintToken(opts: { environmentId?: string; client?: string } = {}) {
+ return signUserActorToken(SESSION_SECRET, {
+ userId: USER_ID,
+ client: opts.client ?? "dashboard-agent",
+ ...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
+ cap: ["read:all"],
+ });
+}
+
+function call(token: string | undefined, organizationId: string) {
+ const request = new Request(
+ `https://example.com/api/v1/dashboard-agent/eval-policy?organizationId=${organizationId}`,
+ token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
+ );
+
+ return loader({ request, params: {}, context: {} as any }) as Promise;
+}
+
+describe("dashboard agent eval policy", () => {
+ beforeEach(() => {
+ mocks.organizationFindFirst.mockReset();
+ // Membership is the query's own filter, so the stub honours it rather than assuming it.
+ mocks.organizationFindFirst.mockImplementation(async ({ where }: any) =>
+ where.id === MEMBER_ORG && where.members?.some?.userId === USER_ID
+ ? { featureFlags: {} }
+ : null
+ );
+ });
+
+ it("401s without a token", async () => {
+ const response = await call(undefined, MEMBER_ORG);
+
+ expect(response.status).toBe(401);
+ });
+
+ it("403s a user-actor token from another client", async () => {
+ const token = await mintToken({ client: "personal-access-token" });
+
+ const response = await call(token, MEMBER_ORG);
+
+ expect(response.status).toBe(403);
+ expect(await response.json()).toMatchObject({ code: "forbidden_client" });
+ });
+
+ it("400s without an organization", async () => {
+ const token = await mintToken({ environmentId: ENV_IN_MEMBER_ORG });
+
+ const request = new Request("https://example.com/api/v1/dashboard-agent/eval-policy", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const response = (await loader({
+ request,
+ params: {},
+ context: {} as any,
+ })) as Response;
+
+ expect(response.status).toBe(400);
+ });
+
+ it("answers no for an organization the token's user isn't a member of", async () => {
+ const token = await mintToken({ environmentId: ENV_IN_MEMBER_ORG });
+
+ const response = await call(token, OTHER_ORG);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ turnEvalsEnabled: false });
+ });
+
+ it("answers yes for an organization the token's user belongs to", async () => {
+ const token = await mintToken({ environmentId: ENV_IN_MEMBER_ORG });
+
+ const response = await call(token, MEMBER_ORG);
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ turnEvalsEnabled: true });
+ });
+
+ it("scopes by membership rather than by the token's environment claim", async () => {
+ // The route is org-level: the claim doesn't narrow it, and membership alone answers.
+ const token = await mintToken({ environmentId: "env_elsewhere" });
+
+ const response = await call(token, MEMBER_ORG);
+
+ expect(response.status).toBe(200);
+ expect(mocks.organizationFindFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ members: { some: { userId: USER_ID } } }),
+ })
+ );
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentEvalRetention.test.ts b/apps/webapp/test/dashboardAgentEvalRetention.test.ts
new file mode 100644
index 00000000000..4a36876e5a0
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentEvalRetention.test.ts
@@ -0,0 +1,135 @@
+import {
+ createDashboardAgentDb,
+ insertTurnEval,
+ type DashboardAgentDb,
+ type DashboardAgentDbClient,
+} from "@internal/dashboard-agent-db";
+import { postgresTest } from "@internal/testcontainers";
+import type { PrismaClient } from "@trigger.dev/database";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { afterEach, describe, expect, vi } from "vitest";
+
+const ctx = vi.hoisted(() => ({
+ agentDb: undefined as unknown as DashboardAgentDb,
+}));
+
+vi.mock("~/services/dashboardAgentDb.server", () => ({
+ get dashboardAgentDb() {
+ return ctx.agentDb;
+ },
+}));
+
+const { sweepDashboardAgentTurnEvals, TURN_EVAL_RETENTION_MS } =
+ await import("~/services/dashboardAgentEvalRetention.server");
+
+/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
+async function applyAgentSchema(prisma: PrismaClient) {
+ const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
+ const migrations = readdirSync(folder)
+ .filter((file) => file.endsWith(".sql"))
+ .sort();
+ for (const name of migrations) {
+ const sql = readFileSync(path.join(folder, name), "utf8");
+ for (const statement of sql.split("--> statement-breakpoint")) {
+ const trimmed = statement.trim();
+ if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
+ }
+ }
+}
+
+let agentDbClient: DashboardAgentDbClient | undefined;
+let prismaForRaw: PrismaClient | undefined;
+
+async function boot(prisma: PrismaClient, connectionUri: string) {
+ await applyAgentSchema(prisma);
+ agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
+ ctx.agentDb = agentDbClient.db;
+ prismaForRaw = prisma;
+}
+
+afterEach(async () => {
+ await agentDbClient?.close();
+ agentDbClient = undefined;
+});
+
+async function seedEval(args: { chatId: string; turn: number; ageMs: number }) {
+ await insertTurnEval(ctx.agentDb, {
+ chatId: args.chatId,
+ turn: args.turn,
+ organizationId: "org_retention",
+ userId: "user_retention",
+ summary: "the user asked about a failed run",
+ });
+ await prismaForRaw!.$executeRawUnsafe(
+ `update trigger_dashboard_agent.chat_turn_evals
+ set created_at = now() - ($3 || ' seconds')::interval
+ where chat_id = $1 and turn = $2`,
+ args.chatId,
+ args.turn,
+ String(args.ageMs / 1000)
+ );
+}
+
+async function remaining(): Promise> {
+ return prismaForRaw!.$queryRawUnsafe(
+ `select chat_id, turn from trigger_dashboard_agent.chat_turn_evals order by chat_id, turn`
+ );
+}
+
+describe("dashboard agent turn-eval retention", () => {
+ postgresTest(
+ "drops only rows past the retention period",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+
+ await seedEval({ chatId: "chat_old", turn: 0, ageMs: TURN_EVAL_RETENTION_MS + 60_000 });
+ await seedEval({ chatId: "chat_edge", turn: 0, ageMs: TURN_EVAL_RETENTION_MS - 60_000 });
+ await seedEval({ chatId: "chat_new", turn: 0, ageMs: 0 });
+
+ const result = await sweepDashboardAgentTurnEvals();
+ expect(result).toEqual({ purged: 1, failed: 0 });
+
+ const rows = await remaining();
+ expect(rows.map((row) => row.chat_id)).toEqual(["chat_edge", "chat_new"]);
+ }
+ );
+
+ postgresTest(
+ "stops at the batch cap and drains on the next run",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+
+ for (let turn = 0; turn < 5; turn++) {
+ await seedEval({
+ chatId: "chat_backlog",
+ turn,
+ // Oldest first, so the cap takes a deterministic slice.
+ ageMs: TURN_EVAL_RETENTION_MS + 60_000 + (5 - turn) * 1_000,
+ });
+ }
+
+ const first = await sweepDashboardAgentTurnEvals({ limit: 2 });
+ expect(first).toEqual({ purged: 2, failed: 0 });
+ expect(await remaining()).toHaveLength(3);
+
+ const second = await sweepDashboardAgentTurnEvals({ limit: 2 });
+ expect(second).toEqual({ purged: 2, failed: 0 });
+
+ const third = await sweepDashboardAgentTurnEvals({ limit: 2 });
+ expect(third).toEqual({ purged: 1, failed: 0 });
+ expect(await remaining()).toHaveLength(0);
+ }
+ );
+
+ postgresTest(
+ "keeps everything when nothing is old enough",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedEval({ chatId: "chat_fresh", turn: 0, ageMs: 0 });
+
+ expect(await sweepDashboardAgentTurnEvals()).toEqual({ purged: 0, failed: 0 });
+ expect(await remaining()).toHaveLength(1);
+ }
+ );
+});
diff --git a/apps/webapp/test/dashboardAgentHeadStart.test.ts b/apps/webapp/test/dashboardAgentHeadStart.test.ts
new file mode 100644
index 00000000000..03a604f829d
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentHeadStart.test.ts
@@ -0,0 +1,67 @@
+import type { UIMessageChunk } from "ai";
+import { describe, expect, it } from "vitest";
+import {
+ HEAD_START_FAILURE_ERROR_TEXT,
+ writeHeadStartFailureToSessionOut,
+ type DashboardAgentSessionOutWriter,
+} from "~/services/dashboardAgentHeadStart.server";
+
+function createFakeWriter(
+ opts: { failChunk?: Error; failTurnComplete?: Error } = {}
+): DashboardAgentSessionOutWriter & { calls: string[]; chunks: UIMessageChunk[] } {
+ const calls: string[] = [];
+ const chunks: UIMessageChunk[] = [];
+ return {
+ calls,
+ chunks,
+ async writeChunk(chunk) {
+ calls.push("chunk");
+ chunks.push(chunk);
+ if (opts.failChunk) throw opts.failChunk;
+ },
+ async writeTurnComplete() {
+ calls.push("turn-complete");
+ if (opts.failTurnComplete) throw opts.failTurnComplete;
+ },
+ };
+}
+
+describe("writeHeadStartFailureToSessionOut", () => {
+ it("writes an error chunk followed by turn-complete", async () => {
+ const writer = createFakeWriter();
+
+ await writeHeadStartFailureToSessionOut(writer);
+
+ expect(writer.calls).toEqual(["chunk", "turn-complete"]);
+ expect(writer.chunks).toEqual([{ type: "error", errorText: HEAD_START_FAILURE_ERROR_TEXT }]);
+ });
+
+ it("does not leak the underlying failure into the chat", async () => {
+ const writer = createFakeWriter();
+
+ await writeHeadStartFailureToSessionOut(writer);
+
+ const errorText = (writer.chunks[0] as { errorText: string }).errorText;
+ expect(errorText).not.toMatch(/api|key|token|anthropic/i);
+ expect(errorText).toMatch(/again/i);
+ });
+
+ it("still closes the turn when the error chunk fails to write", async () => {
+ const chunkError = new Error("s2 append failed");
+ const writer = createFakeWriter({ failChunk: chunkError });
+
+ // A resumed stream only terminates on turn-complete, so it is written even when the error chunk didn't land.
+ await expect(writeHeadStartFailureToSessionOut(writer)).rejects.toBe(chunkError);
+
+ expect(writer.calls).toEqual(["chunk", "turn-complete"]);
+ });
+
+ it("surfaces a turn-complete write failure to the caller", async () => {
+ const turnCompleteError = new Error("s2 control record failed");
+ const writer = createFakeWriter({ failTurnComplete: turnCompleteError });
+
+ await expect(writeHeadStartFailureToSessionOut(writer)).rejects.toBe(turnCompleteError);
+
+ expect(writer.calls).toEqual(["chunk", "turn-complete"]);
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentImageCsp.test.ts b/apps/webapp/test/dashboardAgentImageCsp.test.ts
new file mode 100644
index 00000000000..287d55e2806
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentImageCsp.test.ts
@@ -0,0 +1,45 @@
+// Source-level for the wiring: importing `entry.server.tsx` boots the whole server
+// graph. The directive itself is asserted through the module it is built from.
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import {
+ buildImgSrcDirective,
+ parseCspImageOrigins,
+ withImgSrc,
+} from "../app/utils/cspImageOrigins";
+
+const source = readFileSync(new URL("../app/entry.server.tsx", import.meta.url), "utf8");
+
+describe("document image CSP", () => {
+ it("declares an img-src directive", () => {
+ expect(buildImgSrcDirective()).toMatch(/^img-src /);
+ });
+
+ it("does not allow images from an arbitrary host", () => {
+ const directive = buildImgSrcDirective(parseCspImageOrigins("https://sso.example.com").origins);
+ expect(directive).not.toMatch(/(^|\s)\*(\s|$)/);
+ // A wildcard host on a provider with public write access is the beacon channel.
+ expect(directive).not.toContain("*");
+ // A bare scheme would allow every host on it, same channel.
+ expect(directive).not.toMatch(/(^|\s)https?:(\s|$)/);
+ });
+
+ it("sets the header on every document response, not only on /login", () => {
+ // The set() call must sit outside the /login branch.
+ const loginBranch = source.slice(
+ source.indexOf('url.pathname.startsWith("/login")'),
+ source.indexOf('"Content-Security-Policy",\n withImgSrc')
+ );
+ expect(loginBranch).toContain("}");
+ expect(source).toContain("withImgSrc(responseHeaders.get(");
+ });
+
+ it("builds the directive from the configured allowlist, not a wildcard literal", () => {
+ expect(source).toContain("parseCspImageOrigins(env.CSP_IMG_SRC_ALLOWLIST");
+ expect(source).not.toContain("googleusercontent.com");
+ });
+
+ it("keeps a route's own img-src", () => {
+ expect(withImgSrc("img-src 'none'", buildImgSrcDirective())).toBe("img-src 'none'");
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentInvestigationSweep.test.ts b/apps/webapp/test/dashboardAgentInvestigationSweep.test.ts
new file mode 100644
index 00000000000..546ef41d4b1
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentInvestigationSweep.test.ts
@@ -0,0 +1,395 @@
+import {
+ createChat,
+ createDashboardAgentDb,
+ getChatMessages,
+ getInvestigation,
+ investigationSettlementMessageId,
+ listStaleOpenInvestigations,
+ settleInvestigationAndCloseCard,
+ upsertInvestigationRevision,
+ type DashboardAgentDb,
+ type DashboardAgentDbClient,
+} from "@internal/dashboard-agent-db";
+import {
+ investigationStateSchema,
+ UNSETTLED_INVESTIGATION_NOTE,
+ type InvestigationState,
+} from "@internal/dashboard-agent-contracts";
+import { postgresTest } from "@internal/testcontainers";
+import type { PrismaClient } from "@trigger.dev/database";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { afterEach, describe, expect, vi } from "vitest";
+
+const ctx = vi.hoisted(() => ({
+ agentDb: undefined as unknown as DashboardAgentDb,
+}));
+
+vi.mock("~/services/dashboardAgentDb.server", () => ({
+ get dashboardAgentDb() {
+ return ctx.agentDb;
+ },
+}));
+
+const { sweepDashboardAgentInvestigations, INVESTIGATION_STALE_MS } =
+ await import("~/services/dashboardAgentInvestigationSweep.server");
+
+/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
+async function applyAgentSchema(prisma: PrismaClient) {
+ const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
+ const migrations = readdirSync(folder)
+ .filter((file) => file.endsWith(".sql"))
+ .sort();
+ for (const name of migrations) {
+ const sql = readFileSync(path.join(folder, name), "utf8");
+ for (const statement of sql.split("--> statement-breakpoint")) {
+ const trimmed = statement.trim();
+ if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
+ }
+ }
+}
+
+let agentDbClient: DashboardAgentDbClient | undefined;
+let prismaForRaw: PrismaClient | undefined;
+
+async function boot(prisma: PrismaClient, connectionUri: string) {
+ await applyAgentSchema(prisma);
+ agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
+ ctx.agentDb = agentDbClient.db;
+ prismaForRaw = prisma;
+}
+
+afterEach(async () => {
+ await agentDbClient?.close();
+ agentDbClient = undefined;
+});
+
+const PROJECT_REF = "proj_sweep";
+const ENV_REF = "env_sweep";
+
+async function seedChat(id: string, options: { deleted?: boolean } = {}) {
+ await createChat(ctx.agentDb, {
+ id,
+ organizationId: "org_sweep",
+ userId: "user_sweep",
+ });
+ if (options.deleted) {
+ await prismaForRaw!.$executeRawUnsafe(
+ `update trigger_dashboard_agent.chats set deleted_at = now() where id = $1`,
+ id
+ );
+ }
+}
+
+function openState(overrides: Partial = {}): InvestigationState {
+ return investigationStateSchema.parse({
+ outcome: "in_progress",
+ severity: "warn",
+ confidence: "medium",
+ title: "send-order-receipt keeps failing",
+ headline: "Checking whether the failures share a payload.",
+ progress: "Reading the run's spans",
+ checkNext: [],
+ hypotheses: [
+ {
+ id: "h1",
+ statement: "The new payload dropped a field the task reads.",
+ verdict: "testing",
+ evidence: [],
+ },
+ ],
+ evidence: [],
+ ...overrides,
+ });
+}
+
+async function seedInvestigation(args: {
+ chatId: string;
+ state: InvestigationState;
+ ageMs?: number;
+}): Promise {
+ const created = await upsertInvestigationRevision(ctx.agentDb, {
+ chatId: args.chatId,
+ projectRef: PROJECT_REF,
+ environmentRef: ENV_REF,
+ state: args.state,
+ });
+ if (!created.ok) throw new Error("the fixture investigation wasn't created");
+
+ if (args.ageMs !== undefined) {
+ await prismaForRaw!.$executeRawUnsafe(
+ `update trigger_dashboard_agent.investigations
+ set updated_at = now() - ($2 || ' milliseconds')::interval where id = $1`,
+ created.id,
+ String(args.ageMs)
+ );
+ }
+ return created.id;
+}
+
+/** Comfortably past the grace window. */
+const STALE_AGE_MS = INVESTIGATION_STALE_MS + 60_000;
+
+describe("the dashboard agent investigation sweep", () => {
+ postgresTest(
+ "settles a card left in_progress, keeping what was established",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_stale");
+ const id = await seedInvestigation({
+ chatId: "chat_stale",
+ state: openState(),
+ ageMs: STALE_AGE_MS,
+ });
+
+ const result = await sweepDashboardAgentInvestigations();
+ expect(result).toMatchObject({ stale: 1, settled: 1, alreadySettled: 0, failed: 0 });
+
+ const row = await getInvestigation(ctx.agentDb, { id });
+ const state = investigationStateSchema.parse(row?.state);
+ expect(state.outcome).toBe("inconclusive");
+ expect(state.confidence).toBe("low");
+ expect(state.headline).toBe(
+ `Checking whether the failures share a payload. ${UNSETTLED_INVESTIGATION_NOTE}`
+ );
+ expect(state.progress).toBeUndefined();
+ expect(state.remediation).toBeUndefined();
+ expect(state.hypotheses).toHaveLength(1);
+ expect(state.title).toBe("send-order-receipt keeps failing");
+ expect(row?.revision).toBe(1);
+
+ // The settled row is invisible on its own: the panel resolves the card from the
+ // transcript, so the closing revision has to be in the chat too.
+ const messages = (await getChatMessages(ctx.agentDb, {
+ chatId: "chat_stale",
+ userId: "user_sweep",
+ organizationId: "org_sweep",
+ })) as { id: string; parts: Record[] }[] | null;
+ expect(messages?.map((message) => message.id)).toEqual([
+ investigationSettlementMessageId(id, 1),
+ ]);
+ const block = messages![0]!.parts[0]!.output.blocks[0];
+ expect(block).toMatchObject({ type: "investigation", id, revision: 1 });
+ expect(block.investigation.outcome).toBe("inconclusive");
+ expect(result.closed).toBe(1);
+
+ // A second run can't stack a second card: the settle is a no-op and the append
+ // is deduped on the same message id.
+ expect(await sweepDashboardAgentInvestigations()).toMatchObject({ stale: 0, settled: 0 });
+ expect(
+ (
+ (await getChatMessages(ctx.agentDb, {
+ chatId: "chat_stale",
+ userId: "user_sweep",
+ organizationId: "org_sweep",
+ })) as unknown[]
+ ).length
+ ).toBe(1);
+ },
+ // This one pays the container boot and the schema replay, and now asserts the
+ // transcript on top.
+ 30_000
+ );
+
+ postgresTest(
+ "drops a fix a concluded card was carrying",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_fix");
+ // An inconclusive card may not offer a fix, so the settle strips remediation.
+ const id = await seedInvestigation({
+ chatId: "chat_fix",
+ state: { ...openState(), remediation: "Raise the timeout." } as InvestigationState,
+ ageMs: STALE_AGE_MS,
+ });
+
+ await sweepDashboardAgentInvestigations();
+
+ const row = await getInvestigation(ctx.agentDb, { id });
+ expect(investigationStateSchema.parse(row?.state).remediation).toBeUndefined();
+ }
+ );
+
+ postgresTest(
+ "leaves a fresh in_progress card alone โ a live turn is never swept",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_fresh");
+ const id = await seedInvestigation({ chatId: "chat_fresh", state: openState() });
+
+ expect(await sweepDashboardAgentInvestigations()).toMatchObject({ stale: 0, settled: 0 });
+ const row = await getInvestigation(ctx.agentDb, { id });
+ expect(investigationStateSchema.parse(row?.state).outcome).toBe("in_progress");
+ expect(row?.revision).toBe(0);
+ }
+ );
+
+ postgresTest(
+ "leaves a card that already has an answer alone",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_done");
+ const id = await seedInvestigation({
+ chatId: "chat_done",
+ state: openState({
+ outcome: "inconclusive",
+ progress: undefined,
+ headline: "Not established: the failures span two versions.",
+ }),
+ ageMs: STALE_AGE_MS,
+ });
+
+ expect(await sweepDashboardAgentInvestigations()).toMatchObject({ stale: 0, settled: 0 });
+ const row = await getInvestigation(ctx.agentDb, { id });
+ expect(row?.revision).toBe(0);
+ expect(investigationStateSchema.parse(row?.state).headline).toBe(
+ "Not established: the failures span two versions."
+ );
+ }
+ );
+
+ postgresTest("skips a card in a deleted chat", async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_gone", { deleted: true });
+ await seedInvestigation({
+ chatId: "chat_gone",
+ state: openState(),
+ ageMs: STALE_AGE_MS,
+ });
+
+ expect(await sweepDashboardAgentInvestigations()).toMatchObject({ stale: 0, settled: 0 });
+ });
+
+ postgresTest(
+ "a turn that concludes the card first wins: the settle is a no-op, not an error",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_race");
+ const id = await seedInvestigation({
+ chatId: "chat_race",
+ state: openState(),
+ ageMs: STALE_AGE_MS,
+ });
+
+ const stale = await listStaleOpenInvestigations(ctx.agentDb, {
+ olderThan: new Date(),
+ limit: 10,
+ });
+ expect(stale.map((row) => row.id)).toEqual([id]);
+
+ const concluded = await upsertInvestigationRevision(ctx.agentDb, {
+ id,
+ chatId: "chat_race",
+ projectRef: PROJECT_REF,
+ environmentRef: ENV_REF,
+ state: openState({
+ outcome: "concluded",
+ confidence: "high",
+ progress: undefined,
+ headline: "receipt.ts:42 reads a field the new payload no longer carries.",
+ remediation: "Guard the dereference and backfill the field.",
+ }),
+ });
+ expect(concluded.ok).toBe(true);
+
+ const result = await sweepDashboardAgentInvestigations({ listStale: async () => stale });
+ expect(result).toMatchObject({
+ stale: 1,
+ settled: 0,
+ closed: 0,
+ alreadySettled: 1,
+ failed: 0,
+ });
+
+ const row = await getInvestigation(ctx.agentDb, { id });
+ const state = investigationStateSchema.parse(row?.state);
+ expect(state.outcome).toBe("concluded");
+ expect(state.headline).not.toContain(UNSETTLED_INVESTIGATION_NOTE);
+ expect(row?.revision).toBe(1);
+ }
+ );
+
+ /**
+ * The failure window. Settling the row and delivering its card used to be two
+ * operations: once the row was terminal, a failed append left a card reading
+ * `in_progress` that nothing would ever repair, because this sweep only selects
+ * `in_progress` rows. They must land together or not at all.
+ */
+ postgresTest(
+ "a card that can't be delivered leaves the row in_progress, so the next run retries it",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_undeliverable");
+ // A state the settle can merge but no card can be rendered from, so the delivery
+ // half genuinely fails against a real database.
+ const id = await seedInvestigation({
+ chatId: "chat_undeliverable",
+ state: { outcome: "in_progress" } as unknown as InvestigationState,
+ ageMs: STALE_AGE_MS,
+ });
+
+ await expect(sweepDashboardAgentInvestigations()).rejects.toThrow(
+ /failed on 1 investigations/
+ );
+
+ // The settle rolled back with the card: no half-applied terminal row.
+ const row = await getInvestigation(ctx.agentDb, { id });
+ expect(row?.revision).toBe(0);
+ expect((row?.state as { outcome?: string }).outcome).toBe("in_progress");
+ expect(
+ await getChatMessages(ctx.agentDb, {
+ chatId: "chat_undeliverable",
+ userId: "user_sweep",
+ organizationId: "org_sweep",
+ })
+ ).toEqual([]);
+
+ // And it is still in the selection, so the sweep keeps trying rather than
+ // leaving a permanent spinner behind.
+ const stale = await listStaleOpenInvestigations(ctx.agentDb, {
+ olderThan: new Date(),
+ limit: 10,
+ });
+ expect(stale.map((candidate) => candidate.id)).toEqual([id]);
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "one failing row doesn't cost the batch, and the run throws so the job retries",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await seedChat("chat_batch");
+ const first = await seedInvestigation({
+ chatId: "chat_batch",
+ state: openState(),
+ ageMs: STALE_AGE_MS + 60_000,
+ });
+ const second = await seedInvestigation({
+ chatId: "chat_batch",
+ state: openState(),
+ ageMs: STALE_AGE_MS,
+ });
+
+ const attempted: string[] = [];
+ await expect(
+ sweepDashboardAgentInvestigations({
+ settleAndClose: async (params) => {
+ attempted.push(params.id);
+ if (params.id === first) throw new Error("the settle failed");
+ return settleInvestigationAndCloseCard(ctx.agentDb, params);
+ },
+ })
+ ).rejects.toThrow(/failed on 1 investigations/);
+
+ expect(attempted).toEqual([first, second]);
+ expect(
+ investigationStateSchema.parse((await getInvestigation(ctx.agentDb, { id: second }))?.state)
+ .outcome
+ ).toBe("inconclusive");
+ const stuck = await getInvestigation(ctx.agentDb, { id: first });
+ expect(stuck?.revision).toBe(0);
+ expect(investigationStateSchema.parse(stuck?.state).outcome).toBe("in_progress");
+ }
+ );
+});
diff --git a/apps/webapp/test/dashboardAgentLegacyMessagesColumn.test.ts b/apps/webapp/test/dashboardAgentLegacyMessagesColumn.test.ts
new file mode 100644
index 00000000000..e6dc7e69271
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentLegacyMessagesColumn.test.ts
@@ -0,0 +1,102 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+/**
+ * `chats.messages` is gone: the transcript lives in `chat_messages`, one row per message.
+ *
+ * TypeScript already catches a reference through the Drizzle schema โ the column isn't
+ * there, so it doesn't compile. A raw-SQL reference compiles fine and fails at runtime,
+ * which is the hole this scan covers. Zero hits today is the point; the test exists so a
+ * reintroduction is caught rather than deployed.
+ */
+
+const ROOT = path.resolve(__dirname, "../../..");
+
+const SCANNED = [
+ "apps/webapp/app",
+ "internal-packages/dashboard-agent/src",
+ "internal-packages/dashboard-agent-db/src",
+];
+
+/**
+ * A tripwire, not a proof. It does not see SQL assembled from separate fragments, queries
+ * outside the scanned directories, anything run by hand or by an external tool, or an
+ * aliased table (`from chats c โฆ c.messages`). Migrations are skipped on purpose.
+ */
+
+/** A qualified reference to the dropped column, in any of the spellings Postgres accepts. */
+const QUALIFIED = /"?\bchats"?\s*\.\s*"?messages"?/i;
+
+/** An unqualified one, inside a literal that is plainly SQL against `chats`. */
+const SQL_LITERAL = /`[^`]*`|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/g;
+const SQL_VERB = /\b(select|insert\s+into|update|delete\s+from)\b/i;
+// The quote is part of the match, not excluded before it: a schema-qualified
+// `"trigger_dashboard_agent"."chats"` has a `"` immediately before the name.
+const NAMES_CHATS = /(? {
+ it("is not referenced by any production source, including in raw SQL", () => {
+ const files = SCANNED.flatMap((dir) => sourceFiles(path.join(ROOT, dir)));
+ // A scan that found nothing to read would pass vacuously.
+ expect(files.length).toBeGreaterThan(200);
+
+ expect(files.flatMap(offences)).toEqual([]);
+ });
+
+ // Without these the scan could rot into a pass-everything no-op.
+ it("catches a schema-qualified update of the column", () => {
+ expect(
+ offencesForText(
+ 'sql`UPDATE "trigger_dashboard_agent"."chats" SET "messages" = ${value} WHERE "id" = ${chatId}`',
+ "fixture"
+ )
+ ).not.toEqual([]);
+ });
+
+ it("catches an unqualified update, and leaves chat_messages alone", () => {
+ expect(offencesForText("sql`update chats set messages = ${next}`", "fixture")).not.toEqual([]);
+ expect(
+ offencesForText("sql`insert into chat_messages (message) values (${row})`", "fixture")
+ ).toEqual([]);
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentRoutes.test.ts b/apps/webapp/test/dashboardAgentRoutes.test.ts
new file mode 100644
index 00000000000..7081b8cce68
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentRoutes.test.ts
@@ -0,0 +1,250 @@
+import { describe, expect, it, vi } from "vitest";
+
+const ctx = vi.hoisted(() => ({
+ environment: {
+ id: "env_1",
+ organizationId: "org_1",
+ projectId: "project_1",
+ slug: "prod",
+ type: "PRODUCTION",
+ project: { id: "project_1", slug: "my-project", externalRef: "proj_1" },
+ organization: { id: "org_1", slug: "my-org" },
+ } as any,
+ runCommit: undefined as undefined | { sha: string; version: string; dirty: boolean },
+ deployment: undefined as any,
+ summaryRows: [] as any[],
+ trendRows: [] as any[],
+}));
+
+vi.mock("~/env.server", async (importOriginal) => {
+ const original = (await importOriginal()) as any;
+ return { ...original };
+});
+
+vi.mock("~/db.server", async () => ({
+ prisma: {},
+ $replica: {
+ workerDeployment: {
+ findFirst: async () => ctx.deployment,
+ },
+ },
+ sqlDatabaseSchema: undefined,
+}));
+
+vi.mock("~/services/uatRoutePreamble.server", () => ({
+ authenticateUatOrApiRequest: async () => ({
+ authenticationResult: { type: "personalAccessToken", result: { userId: "user_1" } },
+ userActor: { userId: "user_1", cap: ["read:runs"] },
+ }),
+}));
+
+vi.mock("~/services/apiAuth.server", async (importOriginal) => {
+ const original = (await importOriginal()) as any;
+ return {
+ ...original,
+ authenticatedEnvironmentForAuthentication: async () => ctx.environment,
+ };
+});
+
+vi.mock("~/services/dashboardAgent.server", () => ({
+ resolveRunCommit: async () => ctx.runCommit ?? null,
+}));
+
+vi.mock("~/services/rbac.server", () => ({
+ rbac: {
+ authenticateBearer: async () => ({
+ ok: true,
+ environment: ctx.environment,
+ subject: { type: "private" },
+ ability: { can: () => true, canSuper: () => true },
+ jwt: undefined,
+ }),
+ },
+}));
+
+const chCalls = vi.hoisted(() => ({ summary: undefined as any, trend: undefined as any }));
+
+vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
+ clickhouseFactory: {
+ getClickhouseForOrganization: async () => ({
+ queueMetrics: {
+ listSummary: async (params: any) => {
+ chCalls.summary = params;
+ return [null, ctx.summaryRows];
+ },
+ depthSparklines: async (params: any) => {
+ chCalls.trend = params;
+ return [null, ctx.trendRows];
+ },
+ },
+ }),
+ },
+}));
+
+import { loader as commitLoader } from "~/routes/api.v1.projects.$projectRef.$env.runs.$runId.commit";
+import { loader as queueMetricsLoader } from "~/routes/api.v1.queues.$queueParam.metrics";
+
+function loaderArgs(url: string, params: Record) {
+ return {
+ request: new Request(url, { headers: { Authorization: "Bearer tr_uat_test" } }),
+ params,
+ context: {} as never,
+ } as never;
+}
+
+describe("GET /api/v1/projects/:projectRef/:env/runs/:runId/commit", () => {
+ it("returns the run's version, commit, and git metadata", async () => {
+ ctx.runCommit = { sha: "a".repeat(40), version: "20260102.1", dirty: false };
+ ctx.deployment = {
+ shortCode: "abc1234",
+ deployedAt: new Date("2026-01-02T09:00:00.000Z"),
+ git: {
+ source: "trigger_github_app",
+ commitMessage: "Batch the receipt sends",
+ commitAuthorName: "Ada",
+ commitRef: "main",
+ pullRequestNumber: 412,
+ pullRequestTitle: "Batch the receipt sends",
+ pullRequestState: "merged",
+ ghUserAvatarUrl: "https://example.invalid/avatar.png",
+ },
+ };
+
+ const res = (await commitLoader(
+ loaderArgs("https://app.trigger.dev/api/v1/projects/proj_1/prod/runs/run_1/commit", {
+ projectRef: "proj_1",
+ env: "prod",
+ runId: "run_1",
+ })
+ )) as Response;
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.runId).toBe("run_1");
+ expect(body.version).toBe("20260102.1");
+ expect(body.sha).toBe("a".repeat(40));
+ expect(body.dirty).toBe(false);
+ expect(body.shortCode).toBe("abc1234");
+ expect(body.git).toEqual({
+ source: "trigger_github_app",
+ commitMessage: "Batch the receipt sends",
+ commitAuthorName: "Ada",
+ commitRef: "main",
+ remoteUrl: undefined,
+ ghUsername: undefined,
+ pullRequestNumber: 412,
+ pullRequestTitle: "Batch the receipt sends",
+ pullRequestState: "merged",
+ });
+ });
+
+ it("404s for a run with no deployed commit", async () => {
+ ctx.runCommit = undefined;
+ ctx.deployment = undefined;
+
+ const res = (await commitLoader(
+ loaderArgs("https://app.trigger.dev/api/v1/projects/proj_1/prod/runs/run_dev/commit", {
+ projectRef: "proj_1",
+ env: "prod",
+ runId: "run_dev",
+ })
+ )) as Response;
+
+ expect(res.status).toBe(404);
+ expect((await res.json()).error).toMatch(/no deployed commit/);
+ });
+
+ it("400s on an unknown environment name", async () => {
+ const res = (await commitLoader(
+ loaderArgs("https://app.trigger.dev/api/v1/projects/proj_1/nope/runs/run_1/commit", {
+ projectRef: "proj_1",
+ env: "nope",
+ runId: "run_1",
+ })
+ )) as Response;
+
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("GET /api/v1/queues/:queueParam/metrics", () => {
+ it("prefixes a task queue, derives throughput, and returns the depth trend", async () => {
+ ctx.summaryRows = [
+ {
+ queue_name: "task/send-receipt",
+ p50_wait_ms: 12_000,
+ p95_wait_ms: 41_000,
+ peak_queued: 4210,
+ started_count: 600,
+ throttled_count: 37,
+ },
+ ];
+ ctx.trendRows = [
+ { queue_name: "task/send-receipt", bucket: "2026-01-01 00:05:00", depth: 120, throttled: 0 },
+ { queue_name: "task/send-receipt", bucket: "2026-01-01 00:00:00", depth: 10, throttled: 0 },
+ ];
+
+ const res = (await queueMetricsLoader(
+ loaderArgs("https://app.trigger.dev/api/v1/queues/send-receipt/metrics?period=1h", {
+ queueParam: "send-receipt",
+ })
+ )) as Response;
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ // `type` defaults to task, so the ClickHouse name carries the prefix.
+ expect(body.queue).toBe("task/send-receipt");
+ expect(chCalls.summary.queueNames).toEqual(["task/send-receipt"]);
+ expect(body.waitMs).toEqual({ p50: 12_000, p95: 41_000 });
+ expect(body.peakQueued).toBe(4210);
+ expect(body.startedCount).toBe(600);
+ // 600 starts over a 60 minute window.
+ expect(body.startedPerMin).toBe(10);
+ expect(body.throttledCount).toBe(37);
+ expect(body.depthTrend).toEqual([10, 120]);
+ });
+
+ it("uses a custom queue's name verbatim and zeroes an unseen queue", async () => {
+ ctx.summaryRows = [];
+ ctx.trendRows = [];
+
+ const res = (await queueMetricsLoader(
+ loaderArgs("https://app.trigger.dev/api/v1/queues/my-queue/metrics?type=custom", {
+ queueParam: "my-queue",
+ })
+ )) as Response;
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.queue).toBe("my-queue");
+ expect(body.period).toBe("1h");
+ expect(body.waitMs).toEqual({ p50: null, p95: null });
+ expect(body.peakQueued).toBe(0);
+ expect(body.startedPerMin).toBe(0);
+ expect(body.depthTrend).toEqual([]);
+ });
+
+ it("keeps an already-prefixed task queue name from being double-prefixed", async () => {
+ ctx.summaryRows = [];
+ ctx.trendRows = [];
+
+ const res = (await queueMetricsLoader(
+ loaderArgs("https://app.trigger.dev/api/v1/queues/task%2Ffoo/metrics", {
+ queueParam: "task%2Ffoo",
+ })
+ )) as Response;
+
+ expect(res.status).toBe(200);
+ expect((await res.json()).queue).toBe("task/foo");
+ });
+
+ it("rejects a period beyond the 7d cap", async () => {
+ const res = (await queueMetricsLoader(
+ loaderArgs("https://app.trigger.dev/api/v1/queues/send-receipt/metrics?period=30d", {
+ queueParam: "send-receipt",
+ })
+ )) as Response;
+
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/apps/webapp/test/dashboardAgentTranscriptStore.test.ts b/apps/webapp/test/dashboardAgentTranscriptStore.test.ts
new file mode 100644
index 00000000000..f6051fa8a3e
--- /dev/null
+++ b/apps/webapp/test/dashboardAgentTranscriptStore.test.ts
@@ -0,0 +1,700 @@
+import {
+ appendChatMessageOnceByChatId,
+ countUserMessages,
+ createChat,
+ createDashboardAgentDb,
+ finalizeChatMessage,
+ getChatMessages,
+ getInvestigation,
+ investigationSettlementMessageId,
+ persistMessages,
+ persistTurn,
+ settleInvestigationAndCloseCard,
+ upsertInvestigationRevision,
+ type DashboardAgentDb,
+ type DashboardAgentDbClient,
+} from "@internal/dashboard-agent-db";
+import {
+ investigationStateSchema,
+ type InvestigationState,
+} from "@internal/dashboard-agent-contracts";
+import { postgresTest } from "@internal/testcontainers";
+import type { PrismaClient } from "@trigger.dev/database";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { afterEach, describe, expect } from "vitest";
+
+/**
+ * The message store's idempotency invariants, against a real table.
+ *
+ * The transcript used to be one JSONB array rewritten on every turn, so a message
+ * another process appended mid-turn survived only if the write merged rather than
+ * replaced. It is now one row per message: identity is `(chat_id, message_id)` and order
+ * is a unique `position`, both enforced by the database rather than by application code.
+ */
+
+let agentDb: DashboardAgentDb;
+let agentDbClient: DashboardAgentDbClient | undefined;
+
+const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
+
+/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
+async function applyAgentSchema(prisma: PrismaClient) {
+ for (const name of readdirSync(MIGRATIONS)
+ .filter((file) => file.endsWith(".sql"))
+ .sort()) {
+ const sql = readFileSync(path.join(MIGRATIONS, name), "utf8");
+ for (const statement of sql.split("--> statement-breakpoint")) {
+ const trimmed = statement.trim();
+ if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
+ }
+ }
+}
+
+const ORG_ID = "org_store";
+const USER_ID = "user_store";
+const PROJECT_REF = "proj_store";
+const ENV_REF = "env_store";
+
+async function boot(prisma: PrismaClient, connectionUri: string, chatId?: string) {
+ await applyAgentSchema(prisma);
+ agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 });
+ agentDb = agentDbClient.db;
+ if (chatId) await createChat(agentDb, { id: chatId, organizationId: ORG_ID, userId: USER_ID });
+}
+
+afterEach(async () => {
+ await agentDbClient?.close();
+ agentDbClient = undefined;
+});
+
+function textMessage(id: string, text = id) {
+ return { id, role: "assistant" as const, parts: [{ type: "text", text }] };
+}
+
+function toolMessage(id: string, state: "input-available" | "output-available") {
+ return {
+ id,
+ role: "assistant" as const,
+ parts: [{ type: "tool-get_query_schema", state, toolCallId: `${id}_call`, input: {} }],
+ };
+}
+
+async function transcript(chatId: string): Promise<{ id: string }[]> {
+ return (await getChatMessages(agentDb, {
+ chatId,
+ userId: USER_ID,
+ organizationId: ORG_ID,
+ })) as { id: string }[];
+}
+
+type StoredRow = { message_id: string; position: number; message: unknown; created_at: Date };
+
+/** The stored rows themselves, which is where identity and position are observable. */
+async function rows(prisma: PrismaClient, chatId: string): Promise {
+ return prisma.$queryRawUnsafe(
+ `select message_id, position, message, created_at
+ from trigger_dashboard_agent.chat_messages
+ where chat_id = $1
+ order by position`,
+ chatId
+ );
+}
+
+/** The position allocator itself: what a wasted reservation is visible in. */
+async function nextPosition(prisma: PrismaClient, chatId: string): Promise {
+ const rows = await prisma.$queryRawUnsafe<{ next_message_position: number }[]>(
+ `select next_message_position from trigger_dashboard_agent.chats where id = $1`,
+ chatId
+ );
+ return rows[0]!.next_message_position;
+}
+
+async function chatStamps(
+ prisma: PrismaClient,
+ chatId: string
+): Promise<{ last_message_at: Date | null; updated_at: Date }[]> {
+ return prisma.$queryRawUnsafe(
+ `select last_message_at, updated_at from trigger_dashboard_agent.chats where id = $1`,
+ chatId
+ );
+}
+
+/** The structural column, which the JSONB payload must never be able to contradict. */
+async function roleOf(prisma: PrismaClient, chatId: string, messageId: string): Promise {
+ const rows = await prisma.$queryRawUnsafe<{ role: string }[]>(
+ `select role from trigger_dashboard_agent.chat_messages where chat_id = $1 and message_id = $2`,
+ chatId,
+ messageId
+ );
+ return rows[0]!.role;
+}
+
+function openState(): InvestigationState {
+ return investigationStateSchema.parse({
+ outcome: "in_progress",
+ severity: "warn",
+ confidence: "medium",
+ title: "send-order-receipt keeps failing",
+ headline: "Checking whether the failures share a payload.",
+ progress: "Reading the run's spans",
+ hypotheses: [],
+ evidence: [],
+ });
+}
+
+describe("invariant 1: a repeated message id creates no row and keeps its position", () => {
+ postgresTest(
+ "a redelivered append writes nothing at all",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_repeat_append";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, { chatId, messages: [textMessage("u1")] });
+ expect(
+ await appendChatMessageOnceByChatId(agentDb, { chatId, message: textMessage("ev:1") })
+ ).toBe(true);
+
+ const before = await rows(prisma, chatId);
+
+ // The same durable event, redelivered.
+ expect(
+ await appendChatMessageOnceByChatId(agentDb, { chatId, message: textMessage("ev:1") })
+ ).toBe(false);
+
+ expect(await rows(prisma, chatId)).toEqual(before);
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a message the turn's snapshot already holds keeps its first position",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_repeat_snapshot";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ const snapshot = [textMessage("u1"), textMessage("a1")];
+ await persistMessages(agentDb, { chatId, messages: snapshot });
+ const before = await rows(prisma, chatId);
+
+ // The next turn re-sends the whole snapshot plus what it produced.
+ await persistMessages(agentDb, { chatId, messages: [...snapshot, textMessage("u2")] });
+
+ const after = await rows(prisma, chatId);
+ expect(after).toHaveLength(3);
+ expect(after.slice(0, 2)).toEqual(before);
+ expect(after[2]!.message_id).toBe("u2");
+ },
+ 30_000
+ );
+});
+
+describe("invariant 2: concurrent different messages get distinct positions", () => {
+ postgresTest(
+ "eight genuinely concurrent appends land eight rows in eight positions",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_concurrent";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ const ids = Array.from({ length: 8 }, (_, i) => `ev:${i}`);
+ const results = await Promise.all(
+ ids.map((id) =>
+ appendChatMessageOnceByChatId(agentDb, { chatId, message: textMessage(id) })
+ )
+ );
+
+ expect(results.every(Boolean)).toBe(true);
+ const stored = await rows(prisma, chatId);
+ expect(stored).toHaveLength(8);
+ expect(new Set(stored.map((row) => row.position)).size).toBe(8);
+ expect(new Set(stored.map((row) => row.message_id))).toEqual(new Set(ids));
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "concurrent batches reserve ranges that don't overlap",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_concurrent_batches";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ // Four turns writing three messages each, all at once. The ranges have to be
+ // disjoint: if two batches read the same allocator value they collide on position.
+ const batches = Array.from({ length: 4 }, (_, batch) =>
+ Array.from({ length: 3 }, (_, index) => textMessage(`b${batch}m${index}`))
+ );
+ await Promise.all(batches.map((messages) => persistMessages(agentDb, { chatId, messages })));
+
+ const stored = await rows(prisma, chatId);
+ expect(stored).toHaveLength(12);
+ expect(new Set(stored.map((row) => row.position)).size).toBe(12);
+ // And each batch's own three messages stayed together and in order.
+ for (const [batch, messages] of batches.entries()) {
+ const positions = messages.map(
+ (message) => stored.find((row) => row.message_id === message.id)!.position
+ );
+ expect(positions, `batch ${batch}`).toEqual([
+ positions[0]!,
+ positions[0]! + 1,
+ positions[0]! + 2,
+ ]);
+ }
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "the database is what forbids two messages sharing a position",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_position_unique";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, { chatId, messages: [textMessage("u1")] });
+ const taken = (await rows(prisma, chatId))[0]!.position;
+
+ // Nothing in the query layer can be relied on here: this writes straight past it.
+ await expect(
+ prisma.$executeRawUnsafe(
+ `insert into trigger_dashboard_agent.chat_messages
+ (chat_id, message_id, position, role, message)
+ values ($1, $2, $3, 'assistant', '{}'::jsonb)`,
+ chatId,
+ "a-different-message",
+ taken
+ )
+ // 23505 is unique_violation, and the key it names is the position constraint's.
+ ).rejects.toThrow(/23505[\s\S]*Key \(chat_id, .?position.?\)/);
+ },
+ 30_000
+ );
+});
+
+describe("invariant 3: an ordinary transcript write can never change a stored message", () => {
+ postgresTest(
+ "a differing body under an existing id leaves the durable row exactly as it was",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_no_implicit_update";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, { chatId, messages: [textMessage("u1")] });
+ // A durable event, appended outside a turn.
+ await appendChatMessageOnceByChatId(agentDb, {
+ chatId,
+ message: textMessage("ev:fired", "send-order-receipt resolved."),
+ });
+ const before = await rows(prisma, chatId);
+
+ // A stale snapshot carrying the same id with a different body. `persistMessages` is
+ // not a finalisation, so it must not be able to rewrite it.
+ await persistMessages(agentDb, {
+ chatId,
+ messages: [textMessage("u1"), textMessage("ev:fired", "something else entirely")],
+ });
+
+ expect(await rows(prisma, chatId)).toEqual(before);
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a completing turn finalises the body it stored mid-flight",
+ async ({ prisma, postgresContainer }) => {
+ // `onTurnStart` stores the turn's messages before the model has finished, so the
+ // transcript first holds a tool call with no result. The completed turn arrives
+ // under the same message id, and what the user was shown has to win.
+ const chatId = "chat_turn_finalises_own_message";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, { chatId, messages: [toolMessage("a1", "input-available")] });
+ const before = await rows(prisma, chatId);
+
+ await persistTurn(agentDb, {
+ chatId,
+ messages: [toolMessage("a1", "output-available")],
+ finalizeMessageIds: ["a1"],
+ session: { publicAccessToken: "pat_store" },
+ });
+
+ const after = await rows(prisma, chatId);
+ expect(after).toHaveLength(1);
+ expect(after[0]!.position).toBe(before[0]!.position);
+ expect(after[0]!.message).toMatchObject({
+ parts: [{ state: "output-available" }],
+ });
+ // A finalisation is not an append: no slot is consumed.
+ expect(await nextPosition(prisma, chatId)).toBe(2);
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a batch carrying the same id twice is refused rather than silently picking one",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_dup_in_batch";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await expect(
+ persistMessages(agentDb, {
+ chatId,
+ messages: [textMessage("a1", "first"), textMessage("a1", "second")],
+ })
+ ).rejects.toThrow(/message id a1 twice in one batch/);
+
+ // And nothing landed: the throw is before any reservation.
+ expect(await rows(prisma, chatId)).toHaveLength(0);
+ expect(await nextPosition(prisma, chatId)).toBe(1);
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a message with no id or no role is refused by name, not by a NOT NULL violation",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_malformed";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ // The shape, never the values: a malformed message can carry user text.
+ await expect(
+ persistMessages(agentDb, {
+ chatId,
+ messages: [
+ { role: "user", parts: [{ type: "text", text: "card 4242 for alice@x.test" }] },
+ ],
+ })
+ ).rejects.toThrow(
+ /Chat chat_malformed was handed a message with no id: object with keys: role, parts$/
+ );
+
+ await expect(
+ persistMessages(agentDb, { chatId, messages: [{ id: "a1", parts: [] }] })
+ ).rejects.toThrow(
+ /Chat chat_malformed was handed a message with no role: object with keys: id, parts$/
+ );
+
+ const leaked = await persistMessages(agentDb, {
+ chatId,
+ messages: [{ role: "user", parts: [{ type: "text", text: "alice@x.test" }] }],
+ }).catch((error: Error) => error.message);
+ expect(leaked).not.toContain("alice@x.test");
+ expect(leaked).not.toContain("4242");
+ },
+ 30_000
+ );
+});
+
+describe("invariant 4: a controlled finalisation changes the body and nothing else", () => {
+ postgresTest(
+ "finalising a message keeps its id, its position and its role",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_finalise";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, {
+ chatId,
+ messages: [textMessage("u1"), textMessage("a1", "still working")],
+ });
+ const before = await rows(prisma, chatId);
+
+ expect(
+ await finalizeChatMessage(agentDb, {
+ chatId,
+ messageId: "a1",
+ expectedRole: "assistant",
+ message: textMessage("a1", "here is the answer"),
+ })
+ ).toBe(true);
+
+ const after = await rows(prisma, chatId);
+ expect(after).toHaveLength(2);
+ expect(after.map((row) => [row.message_id, row.position])).toEqual(
+ before.map((row) => [row.message_id, row.position])
+ );
+ // Only the one message named changed.
+ expect(after[0]!.message).toEqual(before[0]!.message);
+ expect(after[1]!.message).toMatchObject({ parts: [{ text: "here is the answer" }] });
+ expect(await roleOf(prisma, chatId, "a1")).toBe("assistant");
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a finalisation aimed at the wrong role writes nothing",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_finalise_role";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "still working")] });
+ const before = await rows(prisma, chatId);
+
+ // The stored row is an assistant message, so a user finalisation is not its own.
+ expect(
+ await finalizeChatMessage(agentDb, {
+ chatId,
+ messageId: "a1",
+ expectedRole: "user",
+ message: { id: "a1", role: "user", parts: [{ type: "text", text: "hijacked" }] },
+ })
+ ).toBe(false);
+
+ expect(await rows(prisma, chatId)).toEqual(before);
+ expect(await roleOf(prisma, chatId, "a1")).toBe("assistant");
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a finalisation whose body names another message is refused",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_finalise_id";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, { chatId, messages: [textMessage("a1", "still working")] });
+ const before = await rows(prisma, chatId);
+
+ // The row key would stay `a1` while the body claims `a2`, so a later read
+ // would hand the UI a message under the wrong identity.
+ await expect(
+ finalizeChatMessage(agentDb, {
+ chatId,
+ messageId: "a1",
+ expectedRole: "assistant",
+ message: { id: "a2", role: "assistant", parts: [{ type: "text", text: "done" }] },
+ })
+ ).rejects.toThrow(/finalisation target a1 carries body id a2/);
+
+ expect(await rows(prisma, chatId)).toEqual(before);
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "the row's role and the body's role cannot be made to disagree",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_finalise_drift";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ await persistMessages(agentDb, { chatId, messages: [textMessage("a1")] });
+
+ // The column says assistant, the body would say user. Refused outright rather
+ // than stored as a row whose column and payload disagree.
+ await expect(
+ finalizeChatMessage(agentDb, {
+ chatId,
+ messageId: "a1",
+ expectedRole: "assistant",
+ message: { id: "a1", role: "user", parts: [] },
+ })
+ ).rejects.toThrow(/expected role assistant but its body carries user/);
+
+ expect(await roleOf(prisma, chatId, "a1")).toBe("assistant");
+ expect((await rows(prisma, chatId))[0]!.message).toMatchObject({ role: "assistant" });
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a finalisation of a message that isn't there writes nothing",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_finalise_missing";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ expect(
+ await finalizeChatMessage(agentDb, {
+ chatId,
+ messageId: "never-stored",
+ expectedRole: "assistant",
+ message: textMessage("never-stored"),
+ })
+ ).toBe(false);
+ expect(await rows(prisma, chatId)).toHaveLength(0);
+ },
+ 30_000
+ );
+});
+
+describe("invariant 5: re-sending a snapshot is free", () => {
+ postgresTest(
+ "a re-sent snapshot reserves no position, touches no row and writes no timestamp",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_snapshot_free";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ const snapshot = Array.from({ length: 6 }, (_, i) => textMessage(`m${i}`));
+ await persistMessages(agentDb, { chatId, messages: snapshot });
+
+ const before = await rows(prisma, chatId);
+ const positionBefore = await nextPosition(prisma, chatId);
+ const chatBefore = await chatStamps(prisma, chatId);
+
+ await persistMessages(agentDb, { chatId, messages: snapshot });
+ await persistTurn(agentDb, {
+ chatId,
+ messages: snapshot,
+ session: { publicAccessToken: "pat_store" },
+ });
+
+ expect(await rows(prisma, chatId)).toEqual(before);
+ // The allocator is the observable cost: a re-send that reserved slots would grow it.
+ expect(await nextPosition(prisma, chatId)).toBe(positionBefore);
+ expect(await chatStamps(prisma, chatId)).toEqual(chatBefore);
+ },
+ 30_000
+ );
+
+ postgresTest(
+ "a transcript grown by re-sent snapshots spends one position per message",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_snapshot_slots";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ // The real write pattern: every turn hands over the whole transcript again. With
+ // the old insert-everything path this cost 1+2+โฆ+40 = 820 slots for 40 rows.
+ const snapshot: ReturnType[] = [];
+ for (let i = 0; i < 40; i++) {
+ snapshot.push(textMessage(`m${i}`));
+ await persistMessages(agentDb, { chatId, messages: [...snapshot] });
+ }
+
+ expect(await rows(prisma, chatId)).toHaveLength(40);
+ expect(await nextPosition(prisma, chatId)).toBe(41);
+ },
+ 30_000
+ );
+});
+
+describe("a write can no longer lose a message another process appended", () => {
+ postgresTest(
+ "a mid-turn append survives the turn's write, and lands before the turn's later messages",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_midturn";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ // The transcript the turn started from.
+ const snapshot = [textMessage("u1"), textMessage("a1")];
+ await persistMessages(agentDb, { chatId, messages: snapshot });
+
+ // Another process appends while the turn is running.
+ await appendChatMessageOnceByChatId(agentDb, {
+ chatId,
+ message: textMessage("ev:fired"),
+ });
+
+ // The turn ends and writes its own snapshot plus what it produced.
+ await persistTurn(agentDb, {
+ chatId,
+ messages: [...snapshot, textMessage("a2")],
+ session: { publicAccessToken: "pat_store", lastEventId: "1", runId: "run_store" },
+ });
+
+ // It is still there, and sits where it happened: after the turn's snapshot,
+ // before the reply the turn went on to produce.
+ expect((await transcript(chatId)).map((message) => message.id)).toEqual([
+ "u1",
+ "a1",
+ "ev:fired",
+ "a2",
+ ]);
+ },
+ 30_000
+ );
+
+ /**
+ * The worst case. The sweep settles a stale investigation and appends its terminal
+ * card in one transaction; if the next write then replaced the transcript, the card
+ * would be gone for good โ the row is already terminal, so the sweep never selects it
+ * again and the panel is back to "Workingโฆ" for ever.
+ */
+ postgresTest(
+ "a settled investigation's terminal card survives the next persistTurn",
+ async ({ prisma, postgresContainer }) => {
+ const chatId = "chat_settled";
+ await boot(prisma, postgresContainer.getConnectionUri(), chatId);
+
+ const snapshot = [textMessage("u1")];
+ await persistMessages(agentDb, { chatId, messages: snapshot });
+
+ const created = await upsertInvestigationRevision(agentDb, {
+ chatId,
+ projectRef: PROJECT_REF,
+ environmentRef: ENV_REF,
+ state: openState(),
+ });
+ if (!created.ok) throw new Error("the fixture investigation wasn't created");
+
+ const closed = await settleInvestigationAndCloseCard(agentDb, {
+ id: created.id,
+ chatId,
+ note: "Stopped without a verdict.",
+ });
+ expect(closed?.closed).toBe(true);
+ const cardId = investigationSettlementMessageId(created.id, 1);
+
+ await persistTurn(agentDb, {
+ chatId,
+ messages: [...snapshot, textMessage("a1")],
+ session: { publicAccessToken: "pat_store" },
+ });
+
+ expect((await transcript(chatId)).map((message) => message.id)).toContain(cardId);
+ // And the row it belongs to is still terminal, so nothing will re-open it.
+ const row = await getInvestigation(agentDb, { id: created.id });
+ expect(investigationStateSchema.parse(row?.state).outcome).toBe("inconclusive");
+
+ // A later turn carrying the card in its own snapshot still can't rewrite it:
+ // finalisation is for the turn's messages, never for a durable event.
+ const card = (await rows(prisma, chatId)).find((stored) => stored.message_id === cardId)!;
+ await persistTurn(agentDb, {
+ chatId,
+ messages: [{ ...(card.message as Record), tampered: true }],
+ // Even named outright, a durable event is not this turn's to rewrite.
+ finalizeMessageIds: [cardId],
+ session: { publicAccessToken: "pat_store" },
+ });
+ const afterCard = (await rows(prisma, chatId)).find(
+ (stored) => stored.message_id === cardId
+ )!;
+ expect(afterCard.message).toEqual(card.message);
+ },
+ 30_000
+ );
+});
+
+describe("countUserMessages", () => {
+ postgresTest(
+ "counts a user's own messages, and only those",
+ async ({ prisma, postgresContainer }) => {
+ await boot(prisma, postgresContainer.getConnectionUri());
+ await createChat(agentDb, { id: "chat_a", organizationId: ORG_ID, userId: USER_ID });
+ await createChat(agentDb, { id: "chat_b", organizationId: ORG_ID, userId: USER_ID });
+ await createChat(agentDb, { id: "chat_gone", organizationId: ORG_ID, userId: USER_ID });
+ await createChat(agentDb, { id: "chat_other", organizationId: ORG_ID, userId: "user_other" });
+
+ const userMessage = (id: string) => ({
+ id,
+ role: "user" as const,
+ parts: [{ type: "text", text: id }],
+ });
+
+ await persistMessages(agentDb, {
+ chatId: "chat_a",
+ // A consent record is a user message but not a turn the user spent, so the
+ // quota's prefix filter must skip it.
+ messages: [userMessage("u1"), textMessage("a1"), userMessage("watch-request:req_1")],
+ });
+ await persistMessages(agentDb, { chatId: "chat_b", messages: [userMessage("u2")] });
+ await persistMessages(agentDb, { chatId: "chat_gone", messages: [userMessage("u3")] });
+ await persistMessages(agentDb, { chatId: "chat_other", messages: [userMessage("u4")] });
+ await prisma.$executeRawUnsafe(
+ `update trigger_dashboard_agent.chats set deleted_at = now() where id = 'chat_gone'`
+ );
+
+ const scope = { organizationId: ORG_ID, userId: USER_ID };
+ expect(await countUserMessages(agentDb, scope)).toBe(2);
+ expect(await countUserMessages(agentDb, { ...scope, excludeChatId: "chat_b" })).toBe(1);
+ expect(
+ await countUserMessages(agentDb, { organizationId: "org_elsewhere", userId: USER_ID })
+ ).toBe(0);
+ },
+ 30_000
+ );
+});
diff --git a/apps/webapp/test/envJwtActorClaim.test.ts b/apps/webapp/test/envJwtActorClaim.test.ts
new file mode 100644
index 00000000000..97348f06b5e
--- /dev/null
+++ b/apps/webapp/test/envJwtActorClaim.test.ts
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ authenticateUatOrApiRequest: vi.fn<(...args: any[]) => Promise>(),
+ authorizePatEnvironmentAccess: vi.fn<(...args: any[]) => Promise>(),
+}));
+
+vi.mock("~/services/uatRoutePreamble.server", () => ({
+ authenticateUatOrApiRequest: mocks.authenticateUatOrApiRequest,
+}));
+vi.mock("~/services/environmentVariableApiAccess.server", () => ({
+ authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess,
+}));
+vi.mock("~/services/apiAuth.server", () => ({
+ authenticatedEnvironmentForAuthentication: vi.fn(async () => environment),
+ branchNameFromRequest: () => undefined,
+}));
+vi.mock("~/services/logger.server", () => ({
+ logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
+}));
+
+import { validateJWT } from "@trigger.dev/core/v3/jwt";
+import { action } from "~/routes/api.v1.projects.$projectRef.$env.jwt";
+
+const environment = {
+ id: "env_1234",
+ apiKey: "tr_prod_abcdefghijklmnop",
+ organizationId: "org_1234",
+ type: "PRODUCTION" as const,
+ project: { id: "proj_1234" },
+};
+
+const params = { projectRef: "proj_abc", env: "prod" };
+
+function request(body: unknown = {}) {
+ return new Request("https://example.com/api/v1/projects/proj_abc/prod/jwt", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+async function mintedClaims(body?: unknown) {
+ const response = await action({ request: request(body), params, context: {} as any });
+ const { token } = (await response.json()) as { token: string };
+ const result = await validateJWT(token, environment.apiKey);
+ if (!result.ok) throw new Error("minted token failed validation");
+ return result.payload as Record;
+}
+
+describe("env JWT exchange โ act claim", () => {
+ beforeEach(() => {
+ mocks.authenticateUatOrApiRequest.mockReset();
+ mocks.authorizePatEnvironmentAccess.mockReset();
+ mocks.authorizePatEnvironmentAccess.mockResolvedValue(undefined);
+ });
+
+ it("stamps the PAT's user with the personal-access-token client", async () => {
+ mocks.authenticateUatOrApiRequest.mockResolvedValue({
+ authenticationResult: { type: "personalAccessToken", result: { userId: "usr_42" } },
+ });
+
+ const claims = await mintedClaims();
+
+ expect(claims.act).toEqual({ sub: "usr_42", client: "personal-access-token" });
+ expect(claims.sub).toBe(environment.id);
+ });
+
+ it("passes through a user-actor token's own client", async () => {
+ mocks.authenticateUatOrApiRequest.mockResolvedValue({
+ authenticationResult: { type: "personalAccessToken", result: { userId: "usr_7" } },
+ userActor: { userId: "usr_7", client: "dashboard-agent", cap: ["read:runs"] },
+ });
+
+ const claims = await mintedClaims({ claims: { scopes: ["read:runs"] } });
+
+ expect(claims.act).toEqual({ sub: "usr_7", client: "dashboard-agent" });
+ expect(claims.scopes).toEqual(["read:runs"]);
+ });
+
+ it("omits act for an org access token (no user)", async () => {
+ mocks.authenticateUatOrApiRequest.mockResolvedValue({
+ authenticationResult: {
+ type: "organizationAccessToken",
+ result: { organizationId: "org_1" },
+ },
+ });
+
+ const claims = await mintedClaims();
+
+ expect(claims.act).toBeUndefined();
+ expect(claims.sub).toBe(environment.id);
+ });
+
+ it("401s without a token", async () => {
+ mocks.authenticateUatOrApiRequest.mockResolvedValue(undefined);
+
+ const response = await action({ request: request(), params, context: {} as any });
+
+ expect(response.status).toBe(401);
+ });
+});
diff --git a/apps/webapp/test/queryScope.test.ts b/apps/webapp/test/queryScope.test.ts
new file mode 100644
index 00000000000..8aadd2439df
--- /dev/null
+++ b/apps/webapp/test/queryScope.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import { queryScopeCeilingFor, resolveQueryScope } from "~/v3/queryScope";
+
+// The public query API takes `scope` in the request body and executeQuery isolates
+// on it, so without this the body โ not the credential โ decided how much data a
+// caller could read.
+describe("the query scope ceiling", () => {
+ it("caps a public access token at its own environment", () => {
+ expect(queryScopeCeilingFor("PUBLIC_JWT")).toBe("environment");
+ });
+
+ it("leaves every other bearer credential uncapped", () => {
+ expect(queryScopeCeilingFor("PRIVATE")).toBe("unbounded");
+ });
+});
+
+describe("resolveQueryScope", () => {
+ // An environment token asking to read the whole organization is refused, not
+ // quietly narrowed: an org-shaped question answered with one environment's
+ // numbers looks like an answer and isn't.
+ it("rejects an organization-scoped body from an environment token", () => {
+ const decision = resolveQueryScope({ ceiling: "environment", requested: "organization" });
+ expect(decision.ok).toBe(false);
+ expect(decision.ok === false && decision.error).toContain("scoped to one environment");
+ });
+
+ it("rejects a project-scoped body from an environment token", () => {
+ expect(resolveQueryScope({ ceiling: "environment", requested: "project" }).ok).toBe(false);
+ });
+
+ // The route's body schema defaults `scope` to "environment", so "no scope" and
+ // the token's ceiling are the same request.
+ it("allows an environment token its own environment", () => {
+ expect(resolveQueryScope({ ceiling: "environment", requested: "environment" })).toEqual({
+ ok: true,
+ scope: "environment",
+ });
+ });
+
+ // A secret key, a PAT-exchanged session or the Query page still query at org
+ // scope: they are not environment-bound credentials.
+ it("leaves an uncapped caller's organization scope alone", () => {
+ expect(resolveQueryScope({ ceiling: "unbounded", requested: "organization" })).toEqual({
+ ok: true,
+ scope: "organization",
+ });
+ });
+});
diff --git a/apps/webapp/test/queueRetrieveJwt.test.ts b/apps/webapp/test/queueRetrieveJwt.test.ts
new file mode 100644
index 00000000000..2916174b1b4
--- /dev/null
+++ b/apps/webapp/test/queueRetrieveJwt.test.ts
@@ -0,0 +1,26 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+/**
+ * The agent reads a queue's live row โ paused, depth, limit โ through the environment JWT it
+ * exchanges its delegated token for. Metrics already answer that JWT; without the same on the
+ * retrieve route the agent got a 401, which reaches the model as absent data and had it
+ * telling users a queue of thousands of runs did not exist.
+ */
+const ROUTE = "apps/webapp/app/routes/api.v1.queues.$queueParam.ts";
+const METRICS = "apps/webapp/app/routes/api.v1.queues.$queueParam.metrics.ts";
+
+describe("queue retrieve accepts an environment JWT", () => {
+ const source = readFileSync(ROUTE, "utf8");
+
+ it("allows the JWT, like its own metrics route does", () => {
+ expect(source).toContain("allowJWT: true");
+ expect(readFileSync(METRICS, "utf8")).toContain("allowJWT: true");
+ });
+
+ it("keeps the queues scope as the gate", () => {
+ // Widening who may ask must not widen what they may read.
+ expect(source).toContain('resource: () => ({ type: "queues" })');
+ expect(source).toContain('action: "read"');
+ });
+});
diff --git a/apps/webapp/test/rbacFallbackBranch.test.ts b/apps/webapp/test/rbacFallbackBranch.test.ts
index 6745d8b117f..2887c94ad9e 100644
--- a/apps/webapp/test/rbacFallbackBranch.test.ts
+++ b/apps/webapp/test/rbacFallbackBranch.test.ts
@@ -1,5 +1,5 @@
import { postgresTest } from "@internal/testcontainers";
-import plugin from "@trigger.dev/rbac";
+import plugin, { signUserActorToken } from "@trigger.dev/rbac";
import { createHash } from "node:crypto";
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { type PrismaClient } from "@trigger.dev/database";
@@ -583,3 +583,94 @@ describe("RBAC fallback โ branch header guards", () => {
}
);
});
+
+const USER_ACTOR_SECRET = "test-user-actor-secret";
+
+function userActorController(prisma: PrismaClient) {
+ return plugin.create(
+ { primary: prisma, replica: prisma },
+ { forceFallback: true, userActorSecret: USER_ACTOR_SECRET }
+ );
+}
+
+describe("RBAC fallback โ user-actor tokens", () => {
+ const stubPrisma = {} as unknown as PrismaClient;
+
+ it("grants a capped token only what its cap says", async () => {
+ const rbac = userActorController(stubPrisma);
+ const token = await signUserActorToken(USER_ACTOR_SECRET, {
+ userId: "usr_1",
+ client: "dashboard-agent",
+ environmentId: "env_1",
+ cap: ["read:runs", "read:apiKeys"],
+ });
+
+ const result = await rbac.authenticateUserActor(bearerRequest(token), {});
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.ability.can("read", { type: "runs" })).toBe(true);
+ expect(result.ability.can("read", { type: "apiKeys" })).toBe(true);
+ expect(result.ability.can("read", { type: "envvars" })).toBe(false);
+ expect(result.ability.can("write", { type: "envvars" })).toBe(false);
+ expect(result.ability.can("write", { type: "runs" })).toBe(false);
+ expect(result.ability.canSuper()).toBe(false);
+ });
+
+ it("gives a token with no cap reads only, never writes", async () => {
+ const rbac = userActorController(stubPrisma);
+ const token = await signUserActorToken(USER_ACTOR_SECRET, {
+ userId: "usr_1",
+ client: "cli",
+ });
+
+ const result = await rbac.authenticateUserActor(bearerRequest(token), {});
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.ability.can("read", { type: "apiKeys" })).toBe(true);
+ expect(result.ability.can("read", { type: "runs" })).toBe(true);
+ expect(result.ability.can("write", { type: "envvars" })).toBe(false);
+ expect(result.ability.can("write", { type: "runs" })).toBe(false);
+ expect(result.ability.can("trigger", { type: "tasks" })).toBe(false);
+ expect(result.ability.canSuper()).toBe(false);
+ });
+
+ it("carries the environment claim on the result and the subject", async () => {
+ const rbac = userActorController(stubPrisma);
+ const token = await signUserActorToken(USER_ACTOR_SECRET, {
+ userId: "usr_1",
+ client: "dashboard-agent",
+ environmentId: "env_1",
+ });
+
+ const result = await rbac.authenticateUserActor(bearerRequest(token), {});
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.claims?.environmentId).toBe("env_1");
+ expect(result.subject).toMatchObject({ type: "userActor", environmentId: "env_1" });
+ });
+
+ postgresTest("leaves a personal access token permissive", async ({ prisma }) => {
+ const { user } = await createTestOrgProjectWithMember(prisma);
+ const rbac = userActorController(prisma);
+ const pat = `tr_pat_${uniqueId("tok")}`;
+ await prisma.personalAccessToken.create({
+ data: {
+ name: "cli",
+ encryptedToken: {},
+ obfuscatedToken: "tr_pat_****",
+ hashedToken: createHash("sha256").update(pat).digest("hex"),
+ userId: user.id,
+ },
+ });
+
+ const result = await rbac.authenticatePat(bearerRequest(pat), {});
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.ability.can("write", { type: "envvars" })).toBe(true);
+ expect(result.ability.can("trigger", { type: "tasks" })).toBe(true);
+ });
+});
diff --git a/apps/webapp/test/reportHealth.test.ts b/apps/webapp/test/reportHealth.test.ts
index 22f940ffbdd..0b3380def32 100644
--- a/apps/webapp/test/reportHealth.test.ts
+++ b/apps/webapp/test/reportHealth.test.ts
@@ -6,7 +6,7 @@ import {
type HealthInput,
} from "~/presenters/v3/reports/health/health";
-/** Golden A โ degraded: env concurrency-limit saturation, backlog drains. */
+/** Golden A, degraded: env concurrency-limit saturation, backlog drains. */
const INPUT_A: HealthInput = {
scope: "prod",
period: "last 1h",
@@ -20,7 +20,12 @@ const INPUT_A: HealthInput = {
normalP95Ms: 7000,
series: [7000, 12000, 20000, 30000, 38000, 42000],
},
- throughput: { donePerMin: 820, triggeredPerMin: 1150, normalTriggeredPerMin: 1100 },
+ throughput: {
+ finishedPerMin: 820,
+ completedPerMin: 820,
+ triggeredPerMin: 1150,
+ normalTriggeredPerMin: 1100,
+ },
failures: { rate: 0.013, normalRate: 0.011, series: [0.011, 0.011, 0.012, 0.013] },
duration: { p95Ms: 1200, normalP95Ms: 1180 },
liveness: { telemetryAgeMs: 4000 },
@@ -33,7 +38,7 @@ const INPUT_A: HealthInput = {
},
};
-/** Golden B โ everything healthy. */
+/** Golden B, everything healthy. */
const INPUT_B: HealthInput = {
scope: "prod",
period: "last 1h",
@@ -43,7 +48,12 @@ const INPUT_B: HealthInput = {
flowSource: "queue_metrics_v1",
pending: { now: 84, normal: 120, series: [110, 96, 88, 90, 84], estimated: false },
startLatency: { p95Ms: 6000, normalP95Ms: 7000, series: [6500, 6200, 6000, 5900, 6000] },
- throughput: { donePerMin: 1000, triggeredPerMin: 1000, normalTriggeredPerMin: 1000 },
+ throughput: {
+ finishedPerMin: 1000,
+ completedPerMin: 1000,
+ triggeredPerMin: 1000,
+ normalTriggeredPerMin: 1000,
+ },
failures: { rate: 0.009, normalRate: 0.011, series: [0.01, 0.009, 0.009] },
duration: { p95Ms: 1100, normalP95Ms: 1180 },
liveness: { telemetryAgeMs: 2000 },
@@ -70,10 +80,9 @@ describe("health cause tree (Golden A โ env limit saturation)", () => {
share: 0.82,
of: "pending",
});
- expect(flow.exclusions).toEqual([]); // env-limit saturation rules nothing out...
+ expect(flow.exclusions).toEqual([]);
expect(flow.observations).toEqual([
- // ...it states supporting facts instead.
- { code: "not_workers_platform", evidence: { donePerMin: 820 } },
+ { code: "not_workers_platform", evidence: { finishedPerMin: 820 } },
{ code: "nothing_dead_lettered", evidence: { dlq: 0 } },
]);
expect(flow.read).toBe("saturation_chain");
@@ -81,9 +90,10 @@ describe("health cause tree (Golden A โ env limit saturation)", () => {
expect(concurrency.annotation).toEqual({ code: "pinned_minutes", value: 40 });
});
- it("footer = raise limit + do-nothing (drains)", () => {
+ it("footer = raise the limit (self-serve) + docs + do-nothing (drains)", () => {
expect(vm.footer).toEqual([
{ code: "raise_env_limit", link: "concurrency" },
+ { code: "concurrency_docs", link: "concurrency" },
{ code: "do_nothing_drains", value: 2.3 },
]);
});
@@ -92,30 +102,27 @@ describe("health cause tree (Golden A โ env limit saturation)", () => {
expect(renderReportMarkdown(vm)).toMatchInlineSnapshot(`
"/report health prod ยท last 1h ยท vs your 7d normal
- ๐ก Flow slowing ยท ๐ข Execution healthy ยท ๐ข data fresh
+ ๐ก Flow slowing โ at your env concurrency limit for the last 40 min
- FLOW ๐ก at your env concurrency limit (last 40 min)
-
- concurrency 100/100 โโ
โโโโโโ pinned 40 of last 60 min
+ concurrency 100/100 โโ
โโโโโโ 40 min at limit
pending 1,910 โ 16ร โโโโโ
โ
โโ (normal ~120)
start latency p95 42s โ 6ร โโโโโโโโ (normal ~7s)
- worst queue email-sends โ 82% of pending
-
- read: limit saturated โ incoming work exceeds capacity โ backlog grows
- runs are completing at ~820/min
+ why: 82% of pending is email-sends
+ runs are finishing at ~820/min
nothing dead-lettered
- EXECUTION ๐ข the runs that DO start are fine
+ ๐ข EXECUTION the runs that DO start are fine
- failures 1.3% (normal ~1.1%) ยท durations normal
- read: runs are completing normally
+ ๐ข LIVENESS fresh โ telemetry current, updated 4s ago
- LIVENESS ๐ข fresh โ telemetry current, updated 4s ago
+ read: limit saturated โ incoming work exceeds capacity โ backlog grows
+ runs are completing normally
โ Raise the env concurrency limit
+ Read concurrency docs
or do nothing โ backlog drains in ~2.3 min once triggers ease"
`);
});
@@ -134,13 +141,22 @@ describe("health (Golden B โ healthy)", () => {
expect(renderReportMarkdown(vm)).toMatchInlineSnapshot(`
"/report health prod ยท last 1h ยท vs your 7d normal
- ๐ข Flow healthy ยท ๐ข Execution healthy ยท ๐ข data fresh
+ ๐ข Flow healthy โ starting normally
+
+ start latency p95 6s โ flat โโโ
โ
โโโโ (normal ~7s)
+
+ pending 84 โ flat โโโโโโโโ (normal ~120)
- FLOW ๐ข starting normally โ pending 84 (normal ~120) ยท starts p95 6s
+ throughput 0/min
+ done 1,000/min
+ triggered 1,000/min
- EXECUTION ๐ข completing normally โ failures 0.9% (normal ~1.1%) ยท durations normal
+ ๐ข EXECUTION the runs that DO start are fine
- LIVENESS ๐ข fresh โ telemetry current, updated 2s ago
+ ๐ข LIVENESS fresh โ telemetry current, updated 2s ago
+
+ read: runs are starting on time
+ runs are completing normally
โ nothing to do"
`);
@@ -162,11 +178,11 @@ describe("snapshot fallback path flags the estimated backlog trend", () => {
},
};
- it("renders an (estimated) caveat on the proxy series, not a bare sparkline", () => {
+ it("renders an estimated caveat on the proxy series, not a bare sparkline", () => {
const vm = interpret(snapshot);
expect(vm.findings.find((f) => f.type === "flow")!.reason).toBe("backlog");
const md = renderReportMarkdown(vm);
- expect(md).toContain("(estimated)"); // the human surface signals the trend is a proxy
+ expect(md).toContain("(estimated from a proxy signal)");
});
});
@@ -178,14 +194,12 @@ describe("liveness trust guard (telemetry freshness)", () => {
expect(execution.reason).toBe("unknown");
expect(execution.read).toBe("data_stale");
expect(vm.summary.severity).toBe("crit");
- // #6: footer points at the pipeline, not "raise the env limit" off stale data.
expect(vm.footer).toEqual([{ code: "check_control_plane", link: "status" }]);
});
it("no freshness signal is 'unknown', NOT stale โ it does not trust-guard execution", () => {
const unknown: HealthInput = {
...INPUT_A,
- // healthy execution so we can see the guard did NOT fire.
failures: { rate: 0.009, normalRate: 0.011, series: [0.009] },
liveness: { telemetryAgeMs: null },
};
@@ -193,17 +207,29 @@ describe("liveness trust guard (telemetry freshness)", () => {
const execution = vm.findings.find((f) => f.type === "execution")!;
const liveness = vm.findings.find((f) => f.type === "liveness")!;
expect(liveness.reason).toBe("freshness_unknown");
- expect(liveness.severity).toBe("ok"); // no signal is NEUTRAL, not a warning
+ expect(liveness.severity).toBe("ok");
expect(execution.reason).not.toBe("unknown");
});
+ it("no freshness signal is never TRUSTWORTHY, even though the human verdict stays neutral", () => {
+ // The machine field must not claim trust it lacks.
+ const vm = interpret({ ...INPUT_B, liveness: { telemetryAgeMs: null } });
+ expect(vm.summary.severity).toBe("ok");
+ expect(vm.facts).toMatchObject({
+ trustworthy: false,
+ telemetry: "none",
+ untrustworthyReason: "telemetry_absent",
+ });
+ expect(interpret({ ...INPUT_B, liveness: { telemetryAgeMs: 120_000 } }).facts).toMatchObject({
+ trustworthy: true,
+ telemetry: "lagging",
+ });
+ });
+
it("a healthy but idle env (no telemetry signal) reads overall green, not yellow", () => {
- // Golden B is all-healthy; drop its telemetry signal -> the verdict must stay ok, since
- // "freshness unknown" is neutral and must not drag a fine env into a yellow report.
const vm = interpret({ ...INPUT_B, liveness: { telemetryAgeMs: null } });
expect(vm.summary.severity).toBe("ok");
expect(vm.findings.find((f) => f.type === "liveness")!.reason).toBe("freshness_unknown");
- // ...but the marker is NEUTRAL (โช), not a confident green โ the state is genuinely unknown.
const md = renderReportMarkdown(vm);
expect(md).toContain("โช");
expect(md).not.toContain("๐ก");
@@ -217,11 +243,7 @@ describe("isPendingIncreasing", () => {
});
});
-/**
- * Fixed-priority cause tree: the first discriminator that fires wins. Each case starts
- * from Golden A and overrides ONLY flow evidence so the intended discriminator matches
- * (Golden A itself covers env_limit_saturation).
- */
+/** Fixed-priority cause tree: the first discriminator that fires wins. */
describe("flow cause tree โ cause selection per discriminator", () => {
const withFlow = (
flowEvidence: Partial,
@@ -240,7 +262,6 @@ describe("flow cause tree โ cause selection per discriminator", () => {
});
it("dequeue_stall โ capacity idle while the backlog grows", () => {
- // running far below the limit (0.1) with a rising backlog + elevated latency.
expect(flowReason(withFlow({ runningSeries: Array(9).fill(10) }))).toBe("dequeue_stall");
});
@@ -251,8 +272,6 @@ describe("flow cause tree โ cause selection per discriminator", () => {
});
it("selects queue throttling over dequeue stall when both shapes match", () => {
- // low running (would look like a stall) BUT the queue is throttling โ the known config
- // bottleneck must win, not "it's on our side".
expect(flowReason(withFlow({ runningSeries: Array(9).fill(10), throttledShare: 0.5 }))).toBe(
"queue_limit_throttling"
);
@@ -270,8 +289,7 @@ describe("flow cause tree โ cause selection per discriminator", () => {
});
it("trigger_surge โ new volume with no baseline (multiplier can't be computed)", () => {
- // normal 0 makes a multiplier meaningless, so an absolute rate selects "new volume"
- // instead of dropping to the v1 fallback (a spike from a zero baseline was invisible before).
+ // A zero baseline makes the multiplier meaningless, so an absolute rate selects new volume.
const input = withFlow(
{ runningSeries: Array(9).fill(50), throttledShare: 0 },
{ triggeredPerMin: 5000, normalTriggeredPerMin: 0 }
@@ -282,13 +300,15 @@ describe("flow cause tree โ cause selection per discriminator", () => {
});
it("does not select trigger_spike when completions keep pace and pending falls", () => {
- // 3ร the normal trigger rate, but the backlog is draining (net >= 0, pending falling) โ so the
- // spike is NOT the cause of degradation (elevated latency is). Blaming it would contradict
- // its own "queue fills faster than it drains" read.
const input: HealthInput = {
...INPUT_A,
pending: { now: 400, normal: 1000, series: [500, 450, 400], estimated: false },
- throughput: { donePerMin: 3300, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 },
+ throughput: {
+ finishedPerMin: 3300,
+ completedPerMin: 3300,
+ triggeredPerMin: 3300,
+ normalTriggeredPerMin: 1100,
+ },
flowEvidence: {
...INPUT_A.flowEvidence,
runningSeries: Array(9).fill(50),
@@ -296,15 +316,19 @@ describe("flow cause tree โ cause selection per discriminator", () => {
},
};
expect(flowReason(input)).not.toBe("trigger_spike");
- expect(flowReason(input)).toBe("start_latency"); // falls through to the v1 symptom
+ expect(flowReason(input)).toBe("start_latency");
});
it("does not select trigger_surge when new volume is draining", () => {
- // No baseline + high volume, but completions outpace triggers and the backlog falls โ not a backup.
const input: HealthInput = {
...INPUT_A,
pending: { now: 400, normal: 1000, series: [500, 450, 400], estimated: false },
- throughput: { donePerMin: 6000, triggeredPerMin: 5000, normalTriggeredPerMin: 0 },
+ throughput: {
+ finishedPerMin: 6000,
+ completedPerMin: 6000,
+ triggeredPerMin: 5000,
+ normalTriggeredPerMin: 0,
+ },
flowEvidence: {
...INPUT_A.flowEvidence,
runningSeries: Array(9).fill(50),
@@ -322,8 +346,7 @@ describe("flow cause tree โ cause selection per discriminator", () => {
});
describe("env_limit_saturation read does not claim a start lag that isn't there", () => {
- // Pinned concurrency + rising backlog, but start latency is still healthy โ saturation can grow
- // a backlog before p95 latency crosses its threshold, so the read must not assert "starts lag".
+ // Saturation can grow a backlog before p95 crosses its threshold, so the read must not assert "starts lag".
const input: HealthInput = {
...INPUT_A,
startLatency: { p95Ms: 6000, normalP95Ms: 7000, series: [6000, 6100, 6000, 5900, 6000] },
@@ -345,15 +368,20 @@ describe("env_limit_saturation read does not claim a start lag that isn't there"
describe("trigger spike does not exonerate user code", () => {
const spike = interpret({
...INPUT_A,
- throughput: { donePerMin: 820, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 },
+ throughput: {
+ finishedPerMin: 820,
+ completedPerMin: 820,
+ triggeredPerMin: 3300,
+ normalTriggeredPerMin: 1100,
+ },
flowEvidence: { ...INPUT_A.flowEvidence, runningSeries: Array(9).fill(50), throttledShare: 0 },
});
it("reports execution is healthy but never claims 'NOT a code problem'", () => {
expect(spike.findings.find((f) => f.type === "flow")!.reason).toBe("trigger_spike");
const md = renderReportMarkdown(spike);
- expect(md).toContain("runs that start are completing normally"); // flow exclusion (proven fact)
- expect(md).not.toContain("NOT a code problem"); // a code path may BE flooding the queue
+ expect(md).toContain("runs that start are completing normally");
+ expect(md).not.toContain("NOT a code problem");
});
it("dequeue_stall (platform-side) still reads 'NOT a code problem'", () => {
@@ -372,7 +400,7 @@ describe("ANSI render (terminal)", () => {
it("uses glyphs + ANSI colour, never the markdown status emoji", () => {
expect(ansi).toMatch(/\x1b\[\d+m/); // an ANSI SGR colour code
expect(ansi).toMatch(/[โโ โ]/); // severity glyphs (not emoji)
- expect(ansi).not.toMatch(/[๐ข๐ก๐ด]/u); // emoji are the markdown surface only
+ expect(ansi).not.toMatch(/[๐ข๐ก๐ด]/u);
});
});
@@ -393,29 +421,35 @@ describe("exclusions are proven, not assumed", () => {
);
it("dequeue_stall claims not-your-code AND not-your-config (both proven: healthy exec, no pin, no throttle)", () => {
- // dequeue_stall only fires with no env-pin and no throttling, so "limits aren't the
- // bottleneck" is genuinely proven here (a throttled shape selects queue_limit_throttling).
const codes = exclusionCodes(withFlow({ runningSeries: Array(9).fill(10) }));
expect(codes).toContain("not_your_code");
expect(codes).toContain("not_your_config");
});
it("trigger_spike observes healthy execution without ruling out user code", () => {
- // Backing-up spike (net < 0, pending rising) with healthy execution -> "execution_healthy" as
- // an OBSERVATION, NOT the exclusion "not_your_code": a code path fanning out task.trigger could
- // BE the cause of the spike, so it must not be ruled out.
const healthyInput = withFlow(
{ runningSeries: Array(9).fill(50), throttledShare: 0 },
- { throughput: { donePerMin: 820, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 } }
+ {
+ throughput: {
+ finishedPerMin: 820,
+ completedPerMin: 820,
+ triggeredPerMin: 3300,
+ normalTriggeredPerMin: 1100,
+ },
+ }
);
expect(observationCodes(healthyInput)).toContain("execution_healthy");
expect(exclusionCodes(healthyInput)).not.toContain("not_your_code");
- // Execution failing -> can't even observe that execution is healthy.
const degradedInput = withFlow(
{ runningSeries: Array(9).fill(50), throttledShare: 0 },
{
- throughput: { donePerMin: 820, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 },
+ throughput: {
+ finishedPerMin: 820,
+ completedPerMin: 820,
+ triggeredPerMin: 3300,
+ normalTriggeredPerMin: 1100,
+ },
failures: { rate: 0.2, normalRate: 0.01, series: [0.2] },
}
);
@@ -430,12 +464,12 @@ describe("stale-telemetry trust guard covers flow (not just execution)", () => {
it("marks flow unknown + crit and strips its action / attribution / exclusions / anomaly window", () => {
expect(flow.reason).toBe("unknown");
- expect(flow.severity).toBe("crit"); // consistent across summary / section glyph / JSON
+ expect(flow.severity).toBe("crit");
expect(flow.recommendation).toBeUndefined();
expect(flow.attribution).toBeUndefined();
expect(flow.exclusions).toBeUndefined();
expect(flow.observations).toBeUndefined();
- expect(flow.anomalyWindow).toBeUndefined(); // no stale causal evidence left in the VM
+ expect(flow.anomalyWindow).toBeUndefined();
});
it("marks execution unknown + crit too", () => {
@@ -444,36 +478,35 @@ describe("stale-telemetry trust guard covers flow (not just execution)", () => {
});
it("strips stale-derived metric annotations so format=json can't leak them", () => {
- // Golden A sets concurrency.annotation ("pinned 40 of last 60 min") before the guard runs;
- // a stale feed must not surface that narrative on the raw JSON metrics.
expect(stale.metrics.every((m) => m.annotation === undefined)).toBe(true);
});
it("renders both sections red as unknown, with no stale causal verdict", () => {
- // The unknown headline already says "data stale"; there's no `read:` line to render (it would
- // just repeat that), and no stale causal evidence (anomaly window) survives.
const md = renderReportMarkdown(stale);
expect(md).toContain("๐ด Flow unknown โ data stale");
- expect(md).toContain("๐ด flow can't be assessed");
- expect(md).not.toContain("(last 40 min)"); // anomaly window gone
+ expect(md).toContain("๐ด EXECUTION execution can't be assessed");
+ expect(md).toContain("๐ฉ stale data");
+ expect(md).not.toContain("(last 40 min)");
});
it("drops the CH-derived link from the VM when telemetry is stale", () => {
- // flow's "concurrency" link is gone; only liveness' control-plane link may remain.
expect(stale.links.map((l) => l.key)).not.toContain("concurrency");
});
it("flags the structured facts informational-only so an agent won't act on stale numbers", () => {
- expect(stale.facts).toMatchObject({ trustworthy: false, staleReason: "telemetry_stale" });
- // fresh input is trustworthy.
- expect(interpret(INPUT_A).facts).toMatchObject({ trustworthy: true });
+ expect(stale.facts).toMatchObject({
+ trustworthy: false,
+ telemetry: "stale",
+ untrustworthyReason: "telemetry_stale",
+ });
+ expect(interpret(INPUT_A).facts).toMatchObject({ trustworthy: true, telemetry: "fresh" });
});
});
describe("freshness unknown is distinct from lagging", () => {
- it("renders 'data freshness unknown' in the summary, not 'data lagging'", () => {
+ it("renders the liveness section as 'freshness unknown', not 'data lagging'", () => {
const md = renderReportMarkdown(interpret({ ...INPUT_A, liveness: { telemetryAgeMs: null } }));
- expect(md).toContain("data freshness unknown");
+ expect(md).toContain("โช LIVENESS freshness unknown");
expect(md).not.toContain("data lagging");
});
@@ -482,7 +515,7 @@ describe("freshness unknown is distinct from lagging", () => {
const unknown = interpret({ ...INPUT_A, liveness: { telemetryAgeMs: null } }).findings.find(
(f) => f.type === "flow"
)!;
- expect(unknown.severity).toBe(fresh.severity); // warn, unaffected by unknown freshness
+ expect(unknown.severity).toBe(fresh.severity);
});
it("marks the liveness metric availability 'unknown' so value 0 isn't read as fresh", () => {
@@ -509,3 +542,105 @@ describe("zero baseline is not a false green (absolute floors)", () => {
expect(vm.findings.find((f) => f.type === "execution")!.severity).not.toBe("ok");
});
});
+
+describe("an unmeasurable backlog is not a healthy backlog", () => {
+ // The depth couldn't be measured, so `now` is a placeholder, not a reading.
+ const unmeasured: HealthInput = {
+ ...INPUT_B,
+ pending: { now: 0, series: [], estimated: true, availability: "unknown" },
+ };
+
+ it("reports flow unassessable instead of healthy, with no action off the placeholder", () => {
+ const vm = interpret(unmeasured);
+ const flow = vm.findings.find((f) => f.type === "flow")!;
+ expect(flow.reason).toBe("flow_unmeasured");
+ expect(flow.recommendation).toBeUndefined();
+ expect(flow.attribution).toBeUndefined();
+ expect(vm.footer).toEqual([{ code: "nothing_to_do" }]);
+ expect(vm.facts).toMatchObject({ trustworthy: false, untrustworthyReason: "flow_unmeasured" });
+ });
+
+ it("does not classify the placeholder depth or offer a drain ETA", () => {
+ const vm = interpret({
+ ...unmeasured,
+ // A placeholder that would cross the crit floor if it were classified.
+ pending: { now: 9000, series: [], estimated: true, availability: "unknown" },
+ });
+ const pending = vm.metrics.find((m) => m.id === "pending")!;
+ expect(pending.availability).toBe("unknown");
+ expect(pending.severity).toBe("ok");
+ expect(vm.footer.map((f) => f.code)).not.toContain("do_nothing_drains");
+ });
+
+ it("renders the flow section with the neutral marker and no facts off the placeholder", () => {
+ const md = renderReportMarkdown(interpret(unmeasured));
+ expect(md).toContain("Flow unknown โ queue depth unavailable");
+ expect(md).not.toContain("pending 0");
+ expect(md).not.toContain("๐ข Flow healthy");
+ });
+});
+
+describe("gappy telemetry cannot read as a full window", () => {
+ // 60 expected buckets at a 1-minute cadence, but only 2 arrived, both pinned at the limit.
+ const gappy: HealthInput = {
+ ...INPUT_A,
+ flowEvidence: {
+ ...INPUT_A.flowEvidence,
+ runningSeries: [100, 100],
+ runningBucketsMs: [Date.parse("2026-07-20T11:58:00Z"), Date.parse("2026-07-20T11:59:00Z")],
+ sampling: { bucketMinutes: 1, expectedBuckets: 60 },
+ },
+ };
+
+ it("does not attribute a concurrency cause off 2 of 60 expected buckets", () => {
+ const vm = interpret(gappy);
+ const flow = vm.findings.find((f) => f.type === "flow")!;
+ expect(flow.reason).not.toBe("env_limit_saturation");
+ expect(flow.reason).not.toBe("dequeue_stall");
+ expect(flow.anomalyWindow).toBeUndefined();
+ const concurrency = vm.metrics.find((m) => m.id === "concurrency")!;
+ expect(concurrency.annotation).toBeUndefined();
+ expect(renderReportMarkdown(vm)).not.toContain("pinned 60");
+ });
+
+ it("counts a duration at the real cadence, and a gap breaks the run", () => {
+ // One bucket is missing in the middle, so the trailing pinned run is 3 buckets, not the whole window.
+ const cadence = 60_000;
+ const start = Date.parse("2026-07-20T11:00:00Z");
+ // 34 of 60 buckets pinned, over the pinned-share threshold; the rest busy but not pinned.
+ const running = Array.from({ length: 60 }, (_, i) => (i >= 26 ? 100 : 60));
+ const timestamps = Array.from({ length: 60 }, (_, i) => start + i * cadence);
+ // Drop bucket 57's continuity by pushing it 10 minutes later, making a gap.
+ for (let i = 57; i < 60; i++) timestamps[i] += 10 * cadence;
+ const vm = interpret({
+ ...INPUT_A,
+ flowEvidence: {
+ ...INPUT_A.flowEvidence,
+ runningSeries: running,
+ runningBucketsMs: timestamps,
+ sampling: { bucketMinutes: 1, expectedBuckets: 60 },
+ },
+ });
+ const flow = vm.findings.find((f) => f.type === "flow")!;
+ expect(flow.reason).toBe("env_limit_saturation");
+ expect(flow.anomalyWindow).toEqual({ minutes: 3, touchesEnd: true });
+ });
+});
+
+describe("drain math counts every terminal run, not only completions", () => {
+ it("80 completed + 20 failed against 100 triggered/min reads as stable, not a deficit", () => {
+ const vm = interpret({
+ ...INPUT_B,
+ throughput: {
+ finishedPerMin: 100, // 80 completed + 20 failed all left the queue
+ completedPerMin: 80,
+ triggeredPerMin: 100,
+ normalTriggeredPerMin: 100,
+ },
+ });
+ const throughput = vm.metrics.find((m) => m.id === "throughput")!;
+ expect(throughput.value).toBe(0); // net, not โ20/min
+ expect(throughput.severity).toBe("ok");
+ expect(vm.findings.find((f) => f.type === "flow")!.severity).toBe("ok");
+ });
+});
diff --git a/apps/webapp/test/reportHealthData.test.ts b/apps/webapp/test/reportHealthData.test.ts
index ae29f7a3700..171d735dab8 100644
--- a/apps/webapp/test/reportHealthData.test.ts
+++ b/apps/webapp/test/reportHealthData.test.ts
@@ -5,13 +5,8 @@ import {
type HealthQueryRunner,
loadHealthInput,
} from "~/presenters/v3/reports/health/health-data";
-
-/**
- * Exercises `loadHealthInput`'s ORCHESTRATION through its query seam (`HealthDeps`):
- * source selection, snapshot fallback (empty + throw), dlq parsing, window-from-timeRange.
- * The runner is injected at the IO boundary โ SQL/TRQL translation and CH aggregation are
- * tested by the query service and the `@internal/clickhouse` MV tests.
- */
+import { interpret } from "~/presenters/v3/reports/health/health";
+import { renderReportMarkdown } from "~/presenters/v3/reports/renderMarkdown";
const NOW = new Date("2026-07-22T12:00:00.000Z");
@@ -30,11 +25,12 @@ function makeDeps(opts: {
runsSeries?: Rows;
envSeries?: Rows;
envScalar?: Rows;
- dlqTotal?: Rows;
+ queueTotals?: Rows;
worst?: Rows;
liveWindowMin?: number;
pendingNow?: number;
- throwOnEnv?: boolean;
+ /** Error the env_metrics queries throw (true = a generic, non-rollout failure). */
+ throwOnEnv?: boolean | Error;
redisThrows?: boolean;
}): HealthDeps {
const rangeFor = (period: string) => {
@@ -45,14 +41,18 @@ function makeDeps(opts: {
const timeRange = rangeFor(period);
const wrap = (rows: Rows = []) => ({ rows, timeRange });
const isEnv = query.includes("FROM env_metrics");
- if (isEnv && opts.throwOnEnv) throw new Error("env_metrics unavailable");
- if (query.includes("dlq_total")) return wrap(opts.dlqTotal);
+ if (isEnv && opts.throwOnEnv) {
+ throw opts.throwOnEnv instanceof Error
+ ? opts.throwOnEnv
+ : new Error("env_metrics unavailable");
+ }
+ if (query.includes("dlq_total")) return wrap(opts.queueTotals);
if (isEnv && query.includes("timeBucket")) return wrap(opts.envSeries);
if (isEnv) return wrap(opts.envScalar ?? [{}]);
if (query.includes("FROM queue_metrics")) return wrap(opts.worst);
if (query.includes("task_identifier")) return wrap([]);
if (query.includes("FROM runs") && query.includes("timeBucket")) return wrap(opts.runsSeries);
- return wrap(opts.runs); // runs scalar (live + baseline)
+ return wrap(opts.runs);
};
const lengthOfEnvQueue = opts.redisThrows
? async () => {
@@ -86,7 +86,7 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
{ t: "b", queued: 300, running: 60, throttled: 1, wait_p95: 9000 },
],
envScalar: [{ wait_p95: 9000, avg_queued: 200, env_limit: 100 }],
- dlqTotal: [{ dlq_total: 0 }],
+ queueTotals: [{ dlq_total: 0, total_queued: 100 }],
worst: [
{ name: "email-sends", latest_queued: 82 },
{ name: "other", latest_queued: 18 },
@@ -114,7 +114,7 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
runs: RUNS_SCALAR,
envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 100 }],
envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100 }],
- dlqTotal: [],
+ queueTotals: [],
})
);
@@ -130,7 +130,7 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
makeDeps({
runs: RUNS_SCALAR,
runsSeries: [{ t: "a", triggered: 10, completed: 8, start_latency_p95: 3000, failures: 0 }],
- envSeries: [], // pipeline hasn't populated env_metrics for this env yet
+ envSeries: [],
})
);
@@ -138,7 +138,7 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
expect(input.pending.estimated).toBe(true);
});
- it("env_metrics query throws -> snapshot fallback, never a 500 (bug-2 guard)", async () => {
+ it("a ROLLOUT error (env_metrics not there yet) -> clean snapshot fallback, depth still measured", async () => {
const input = await loadHealthInput(
fakeEnv,
"1h",
@@ -146,12 +146,69 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
makeDeps({
runs: RUNS_SCALAR,
runsSeries: [{ t: "a", triggered: 10, completed: 8, start_latency_p95: 3000, failures: 0 }],
- throwOnEnv: true,
+ throwOnEnv: new Error(
+ "Unable to query clickhouse: Code: 60. DB::Exception: Table trigger_dev.env_metrics_v1 does not exist. (UNKNOWN_TABLE)"
+ ),
+ pendingNow: 12,
})
);
expect(input.flowSource).toBe("snapshot+runs");
expect(input.pending.estimated).toBe(true);
+ expect(input.pending.now).toBe(12);
+ expect(input.pending.availability).toBe("measured");
+ });
+
+ it("an UNEXPECTED env_metrics failure + Redis down never becomes 'backlog 0'", async () => {
+ // Falling back to the snapshot on any error let a failed Redis call read as a confident depth 0.
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ runsSeries: [{ t: "a", triggered: 10, completed: 8, start_latency_p95: 3000, failures: 0 }],
+ throwOnEnv: new Error("Unable to query clickhouse: Code: 241. Memory limit exceeded"),
+ redisThrows: true,
+ })
+ );
+
+ expect(input.pending.availability).toBe("unknown");
+
+ const vm = interpret(input);
+ const flow = vm.findings.find((f) => f.type === "flow")!;
+ expect(flow.reason).toBe("flow_unmeasured");
+ expect(flow.severity).not.toBe("crit");
+ expect(flow.recommendation).toBeUndefined();
+ expect(vm.facts).toMatchObject({ trustworthy: false, telemetry: "none" });
+ expect(vm.footer).toEqual([{ code: "nothing_to_do" }]);
+
+ const md = renderReportMarkdown(vm);
+ expect(md).not.toContain("pending 0");
+ expect(md).not.toContain("๐ข Flow healthy");
+ });
+
+ it("snapshot path with Redis down marks the depth unknown, not zero", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ runsSeries: [
+ { t: "a", triggered: 100, completed: 10, finished: 10, start_latency_p95: 3000 },
+ ],
+ envSeries: [],
+ redisThrows: true,
+ })
+ );
+
+ expect(input.flowSource).toBe("snapshot+runs");
+ expect(input.pending.availability).toBe("unknown");
+ expect(input.pending.now).toBe(90);
+ expect(
+ interpret(input).findings.find((f) => f.type === "flow")!.recommendation
+ ).toBeUndefined();
});
it("windowMinutes comes from the resolved (clipped) timeRange, not the period string", async () => {
@@ -163,7 +220,7 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
runs: RUNS_SCALAR,
envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 100 }],
envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100 }],
- dlqTotal: [{ dlq_total: 3 }],
+ queueTotals: [{ dlq_total: 3 }],
liveWindowMin: 45,
})
);
@@ -184,7 +241,7 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100, last_bucket: OLD }],
})
);
- // ~60 min from the row timestamp, NOT ~0 from timeRange.to
+ // ~60 min from the row timestamp, not ~0 from timeRange.to
expect(input.liveness.telemetryAgeMs).toBeGreaterThan(50 * 60_000);
});
@@ -195,7 +252,7 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
NOW,
makeDeps({
runs: RUNS_SCALAR,
- // every triggered run also FINISHED this bucket (some failed) -> proxy stays flat at 0
+ // every triggered run also finished this bucket (some failed), so the proxy stays at 0
runsSeries: [
{
t: "a",
@@ -238,5 +295,143 @@ describe("loadHealthInput โ orchestration (query seam)", () => {
);
expect(input.flowSource).toBe("queue_metrics_v1");
expect(input.pending.now).toBe(900);
+ expect(input.pending.availability).toBe("measured");
+ });
+
+ it("worst-queue share divides by the env-wide total, not just the top 20 rows", async () => {
+ // 100 queues, the worst holds 40 of a true total of 200 (20%). Summing only the 20 returned
+ // rows gives 40/80, enough to cross the attribution threshold and name a queue falsely.
+ const worst: Rows = [
+ { name: "email-sends", latest_queued: 40 },
+ ...Array.from({ length: 19 }, (_, i) => ({ name: `q${i}`, latest_queued: 40 / 19 })),
+ ];
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ envSeries: [{ t: "a", queued: 200, running: 50, throttled: 0, wait_p95: 100 }],
+ envScalar: [{ wait_p95: 100, avg_queued: 200, env_limit: 100 }],
+ queueTotals: [{ dlq_total: 0, total_queued: 200 }],
+ worst,
+ })
+ );
+
+ expect(input.flowEvidence.worstQueue).toEqual({ name: "email-sends", share: 0.2 });
+ const flow = interpret(input).findings.find((f) => f.type === "flow")!;
+ expect(flow.attribution).toBeUndefined();
+ });
+
+ it("no queue totals -> no attribution (a share needs a denominator)", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ envSeries: [{ t: "a", queued: 200, running: 50, throttled: 0, wait_p95: 100 }],
+ envScalar: [{ wait_p95: 100, avg_queued: 200, env_limit: 100 }],
+ queueTotals: [],
+ worst: [{ name: "email-sends", latest_queued: 400 }],
+ })
+ );
+ expect(input.flowEvidence.worstQueue).toBeNull();
+ });
+
+ it("carries bucket cadence + timestamps so a gappy series can't read as a full window", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ envSeries: [
+ { t: "2026-07-22 11:58:00", queued: 100, running: 100, throttled: 0, wait_p95: 100 },
+ { t: "2026-07-22 11:59:00", queued: 100, running: 100, throttled: 0, wait_p95: 100 },
+ ],
+ envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100 }],
+ liveWindowMin: 60,
+ })
+ );
+
+ const sampling = input.flowEvidence.sampling!;
+ expect(sampling.bucketMinutes).toBeGreaterThan(0);
+ expect(sampling.expectedBuckets).toBeGreaterThan(10);
+ expect(input.flowEvidence.runningBucketsMs).toHaveLength(2);
+ const vm = interpret(input);
+ expect(vm.findings.find((f) => f.type === "flow")!.reason).not.toBe("env_limit_saturation");
+ expect(vm.metrics.find((m) => m.id === "concurrency")!.annotation).toBeUndefined();
+ });
+
+ it("a quiet snapshot env with only an old run is not reported as a stale pipeline", async () => {
+ // Run activity is not telemetry freshness: an idle env with a healthy pipeline must not be told to check the control plane.
+ const OLD_RUN = "2026-07-22 11:50:00";
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: [{ ...RUNS_SCALAR[0], last_activity: OLD_RUN }],
+ runsSeries: [{ t: "a", triggered: 2, completed: 2, finished: 2, start_latency_p95: 1000 }],
+ envSeries: [],
+ pendingNow: 0,
+ })
+ );
+
+ expect(input.liveness.telemetryAgeMs).toBeNull();
+
+ const vm = interpret(input);
+ const liveness = vm.findings.find((f) => f.type === "liveness")!;
+ expect(liveness.reason).toBe("freshness_unknown");
+ expect(liveness.severity).toBe("ok");
+ expect(liveness.recommendation).toBeUndefined();
+ expect(vm.footer).not.toEqual([{ code: "check_control_plane", link: "status" }]);
+ });
+
+ it("drain rate counts every terminal run: 80 completed + 20 failed vs 100 triggered reads stable", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: [
+ {
+ start_latency_p95: 1000,
+ dur_p95: 1000,
+ failures: 20 * 60,
+ completed: 80 * 60,
+ finished: 100 * 60,
+ triggered: 100 * 60,
+ last_activity: "2026-07-22 11:59:58",
+ },
+ ],
+ envSeries: [{ t: "a", queued: 10, running: 50, throttled: 0, wait_p95: 100 }],
+ envScalar: [{ wait_p95: 100, avg_queued: 10, env_limit: 100 }],
+ liveWindowMin: 60,
+ })
+ );
+
+ expect(input.throughput.finishedPerMin).toBeCloseTo(100);
+ expect(input.throughput.completedPerMin).toBeCloseTo(80);
+ // net = finished minus triggered = 0: the queue is keeping pace, not losing 20/min.
+ const throughput = interpret(input).metrics.find((m) => m.id === "throughput")!;
+ expect(throughput.value).toBeCloseTo(0);
+ expect(throughput.severity).toBe("ok");
+ });
+
+ it("a runs row without the finished column falls back to completions, not to a 0 drain rate", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR, // no `finished` key
+ envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 100 }],
+ envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100 }],
+ liveWindowMin: 60,
+ })
+ );
+ expect(input.throughput.finishedPerMin).toBeCloseTo(100 / 60);
});
});
diff --git a/apps/webapp/test/reportPresenter.test.ts b/apps/webapp/test/reportPresenter.test.ts
new file mode 100644
index 00000000000..73a571eb274
--- /dev/null
+++ b/apps/webapp/test/reportPresenter.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from "vitest";
+import { createReportCache, ReportPresenter } from "~/presenters/v3/reports/ReportPresenter.server";
+import { type ReportLoader } from "~/presenters/v3/reports/report-registry";
+import { type ReportViewModel } from "~/presenters/v3/reports/report-view-model";
+import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
+
+function env(id: string): AuthenticatedEnvironment {
+ return { id } as unknown as AuthenticatedEnvironment;
+}
+
+function viewModel(title: string): ReportViewModel {
+ return {
+ title,
+ scope: "prod",
+ period: "last 1h",
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ windowMinutes: 60,
+ summary: { severity: "ok", statements: [] },
+ findings: [],
+ metrics: [],
+ facts: {},
+ links: [],
+ footer: [],
+ };
+}
+
+type Deferred = {
+ promise: Promise;
+ resolve: () => void;
+ reject: (error: Error) => void;
+};
+
+function deferred(): Deferred {
+ let resolve!: () => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function gatedRegistry(): {
+ registry: Record>;
+ loadCalls: () => number;
+ gate: () => Deferred;
+ nextGate: () => void;
+} {
+ let loads = 0;
+ let current = deferred();
+
+ const registry: Record> = {
+ gated: {
+ tables: ["runs"],
+ load: async () => {
+ loads++;
+ await current.promise;
+ return {};
+ },
+ interpret: () => viewModel("gated"),
+ } as ReportLoader,
+ };
+
+ return {
+ registry,
+ loadCalls: () => loads,
+ gate: () => current,
+ nextGate: () => {
+ current = deferred();
+ },
+ };
+}
+
+describe("ReportPresenter โ single-flight + TTL cache", () => {
+ it("collapses concurrent identical calls into one load", async () => {
+ const { registry, loadCalls, gate } = gatedRegistry();
+ const presenter = new ReportPresenter(registry, createReportCache());
+
+ const a = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ const b = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ const c = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+
+ gate().resolve();
+ const [ra, rb, rc] = await Promise.all([a, b, c]);
+
+ expect(loadCalls()).toBe(1);
+ expect(ra).toBe(rb);
+ expect(rb).toBe(rc);
+ });
+
+ it("does not collapse calls that differ by period or environment", async () => {
+ const { registry, loadCalls, gate } = gatedRegistry();
+ const presenter = new ReportPresenter(registry, createReportCache());
+
+ const calls = [
+ presenter.call({ environment: env("env_1"), key: "gated", period: "1h" }),
+ presenter.call({ environment: env("env_1"), key: "gated", period: "24h" }),
+ presenter.call({ environment: env("env_2"), key: "gated", period: "1h" }),
+ ];
+
+ gate().resolve();
+ await Promise.all(calls);
+
+ expect(loadCalls()).toBe(3);
+ });
+
+ it("runs a fresh load once the cached report has expired", async () => {
+ const { registry, loadCalls, gate, nextGate } = gatedRegistry();
+ // 1ms window, so the second call is past it without waiting on the real TTL.
+ const presenter = new ReportPresenter(registry, createReportCache(1));
+
+ const first = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ gate().resolve();
+ await first;
+
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ nextGate();
+ const second = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ gate().resolve();
+ await second;
+
+ expect(loadCalls()).toBe(2);
+ });
+
+ it("evicts a rejected in-flight entry so the next call retries", async () => {
+ const { registry, loadCalls, gate, nextGate } = gatedRegistry();
+ const presenter = new ReportPresenter(registry, createReportCache());
+
+ const failing = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ gate().reject(new Error("clickhouse exploded"));
+ await expect(failing).rejects.toThrow("clickhouse exploded");
+ expect(loadCalls()).toBe(1);
+
+ nextGate();
+ const retried = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ gate().resolve();
+
+ await expect(retried).resolves.toMatchObject({ title: "gated" });
+ expect(loadCalls()).toBe(2);
+ });
+
+ it("serves a settled report from the cache instead of loading again", async () => {
+ const { registry, loadCalls, gate, nextGate } = gatedRegistry();
+ const presenter = new ReportPresenter(registry, createReportCache());
+
+ const first = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ gate().resolve();
+ await first;
+
+ // A second gate that is never opened: if this call loaded, it would hang.
+ nextGate();
+ const second = await presenter.call({
+ environment: env("env_1"),
+ key: "gated",
+ period: "1h",
+ });
+
+ expect(loadCalls()).toBe(1);
+ expect(second).toMatchObject({ title: "gated" });
+ });
+
+ it("never serves one environment's cached report to another", async () => {
+ const { registry, loadCalls, gate, nextGate } = gatedRegistry();
+ const presenter = new ReportPresenter(registry, createReportCache());
+
+ const first = presenter.call({ environment: env("env_1"), key: "gated", period: "1h" });
+ gate().resolve();
+ await first;
+
+ nextGate();
+ const other = presenter.call({ environment: env("env_2"), key: "gated", period: "1h" });
+ gate().resolve();
+ await other;
+
+ expect(loadCalls()).toBe(2);
+ });
+
+ it("returns undefined for an unknown key without touching the registry", async () => {
+ const { registry, loadCalls } = gatedRegistry();
+ const presenter = new ReportPresenter(registry, createReportCache());
+
+ await expect(
+ presenter.call({ environment: env("env_1"), key: "nope" })
+ ).resolves.toBeUndefined();
+ // `Object.hasOwn`, not `in`: a prototype key must not resolve to a loader.
+ await expect(
+ presenter.call({ environment: env("env_1"), key: "toString" })
+ ).resolves.toBeUndefined();
+ expect(loadCalls()).toBe(0);
+ });
+});
diff --git a/apps/webapp/test/reportsApiRoute.test.ts b/apps/webapp/test/reportsApiRoute.test.ts
new file mode 100644
index 00000000000..c6f21ca9f7c
--- /dev/null
+++ b/apps/webapp/test/reportsApiRoute.test.ts
@@ -0,0 +1,178 @@
+import { buildJwtAbility } from "@trigger.dev/rbac";
+import { describe, expect, it } from "vitest";
+import {
+ isReportKey,
+ reportQueryTables,
+ type ReportQueryTable,
+} from "~/presenters/v3/reports/report-registry";
+import { type ReportViewModel } from "~/presenters/v3/reports/report-view-model";
+import {
+ reportAuthResource,
+ reportResponse,
+ ReportSearchParamsSchema,
+} from "~/presenters/v3/reports/reportsApi.server";
+
+// `everyResource(...)` tags its payload with this Symbol.for marker (see apiBuilder.server.ts).
+const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource");
+
+/** ANSI CSI introducer: present in the coloured render, absent from markdown. */
+const ESC = "\u001b[";
+
+function requiredResources(key: string): { type: string; id: string }[] {
+ const resource = reportAuthResource(key) as unknown as {
+ [EVERY_RESOURCE_MARKER]: true;
+ resources: { type: string; id: string }[];
+ };
+ expect(resource[EVERY_RESOURCE_MARKER]).toBe(true);
+ return resource.resources;
+}
+
+function authorizes(scopes: string[], key: string): boolean {
+ const ability = buildJwtAbility(scopes);
+ const resources = requiredResources(key);
+ return resources.length > 0 && resources.every((r) => ability.can("read", r));
+}
+
+function healthyViewModel(): ReportViewModel {
+ return {
+ title: "health",
+ scope: "prod",
+ period: "last 1h",
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ windowMinutes: 60,
+ summary: {
+ severity: "ok",
+ statements: [{ findingType: "flow", severity: "ok" }],
+ },
+ findings: [{ type: "flow", severity: "ok", reason: "healthy", metricIds: [] }],
+ metrics: [],
+ facts: { runsCompleted: 12 },
+ links: [],
+ footer: [],
+ };
+}
+
+describe("api.v1.reports.$key โ period contract", () => {
+ it("accepts 1h / 24h / 7d", () => {
+ for (const period of ["1h", "24h", "7d"]) {
+ const parsed = ReportSearchParamsSchema.safeParse({ period });
+ expect(parsed.success, period).toBe(true);
+ }
+ });
+
+ it("accepts minutes and weeks", () => {
+ expect(ReportSearchParamsSchema.safeParse({ period: "30m" }).success).toBe(true);
+ expect(ReportSearchParamsSchema.safeParse({ period: "2w" }).success).toBe(true);
+ });
+
+ // Reports bucket by whole minutes, so seconds are rejected rather than silently rounded.
+ it("rejects seconds โ 30s and 90s", () => {
+ expect(ReportSearchParamsSchema.safeParse({ period: "30s" }).success).toBe(false);
+ expect(ReportSearchParamsSchema.safeParse({ period: "90s" }).success).toBe(false);
+ });
+
+ it("rejects garbage and absurd ranges", () => {
+ expect(ReportSearchParamsSchema.safeParse({ period: "nonsense" }).success).toBe(false);
+ expect(ReportSearchParamsSchema.safeParse({ period: "0h" }).success).toBe(false);
+ expect(ReportSearchParamsSchema.safeParse({ period: "999999999d" }).success).toBe(false);
+ });
+
+ it("defaults format to markdown and rejects an unknown format", () => {
+ expect(ReportSearchParamsSchema.parse({}).format).toBe("markdown");
+ expect(ReportSearchParamsSchema.safeParse({ format: "yaml" }).success).toBe(false);
+ });
+});
+
+describe("api.v1.reports.$key โ authorization", () => {
+ it("passes a JWT scoped to read:query", () => {
+ expect(authorizes(["read:query"], "health")).toBe(true);
+ });
+
+ it("passes a JWT scoped to every table the report reads", () => {
+ const scopes = reportQueryTables("health").map((t) => `read:query:${t}`);
+ expect(authorizes(scopes, "health")).toBe(true);
+ });
+
+ it("rejects a JWT scoped to only some of the tables the report reads", () => {
+ expect(authorizes(["read:query:runs"], "health")).toBe(false);
+ });
+
+ it("rejects a JWT with no query scope at all", () => {
+ expect(authorizes(["read:runs"], "health")).toBe(false);
+ });
+
+ it("requires the health report's tables, not a permissive query:all", () => {
+ expect(requiredResources("health")).toEqual([
+ { type: "query", id: "runs" },
+ { type: "query", id: "env_metrics" },
+ { type: "query", id: "queue_metrics" },
+ ]);
+ });
+
+ it("requires every report's tables for an unknown key, so it can't grant on a bad key", () => {
+ expect(requiredResources("nonsense")).toEqual(requiredResources("health"));
+ expect(authorizes(["read:query:runs"], "nonsense")).toBe(false);
+ });
+});
+
+describe("reportQueryTables โ scope derivation from the registry", () => {
+ const registry: Record = {
+ health: { tables: ["runs", "env_metrics", "queue_metrics"] },
+ narrow: { tables: ["runs"] },
+ };
+
+ it("gives a narrower report exactly its own tables", () => {
+ expect(reportQueryTables("narrow", registry)).toEqual(["runs"]);
+ });
+
+ it("still gives the wider report all of its tables", () => {
+ expect(reportQueryTables("health", registry)).toEqual(["runs", "env_metrics", "queue_metrics"]);
+ });
+
+ it("returns the de-duplicated union across reports for an unknown key", () => {
+ expect(reportQueryTables("unknown", registry)).toEqual([
+ "runs",
+ "env_metrics",
+ "queue_metrics",
+ ]);
+ });
+});
+
+describe("api.v1.reports.$key โ formats", () => {
+ it("serves json as the raw view model", async () => {
+ const vm = healthyViewModel();
+ const response = reportResponse(vm, "json");
+
+ expect(response.headers.get("Content-Type")).toContain("application/json");
+ await expect(response.json()).resolves.toEqual(vm);
+ });
+
+ it("serves markdown as text/markdown", async () => {
+ const response = reportResponse(healthyViewModel(), "markdown");
+
+ expect(response.headers.get("Content-Type")).toBe("text/markdown; charset=utf-8");
+ const body = await response.text();
+ expect(body.length).toBeGreaterThan(0);
+ expect(body).not.toContain(ESC);
+ expect(body).not.toContain("[");
+ });
+
+ it("serves ansi as text/plain with escape codes", async () => {
+ const response = reportResponse(healthyViewModel(), "ansi");
+
+ expect(response.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
+ expect(await response.text()).toContain("[");
+ });
+});
+
+describe("api.v1.reports.$key โ report key", () => {
+ it("accepts a registered key", () => {
+ expect(isReportKey("health")).toBe(true);
+ });
+
+ it("rejects an unknown key and prototype keys", () => {
+ for (const key of ["nonsense", "toString", "__proto__", "constructor"]) {
+ expect(isReportKey(key), key).toBe(false);
+ }
+ });
+});
diff --git a/apps/webapp/test/resolveTriggerUri.test.ts b/apps/webapp/test/resolveTriggerUri.test.ts
new file mode 100644
index 00000000000..d394beea591
--- /dev/null
+++ b/apps/webapp/test/resolveTriggerUri.test.ts
@@ -0,0 +1,195 @@
+import { formatTriggerUri } from "@internal/dashboard-agent-contracts";
+import { describe, expect, it } from "vitest";
+import { resolveTriggerUri, type TriggerUriScope } from "~/services/resolveTriggerUri.server";
+import {
+ v3DeploymentVersionPath,
+ v3ErrorPath,
+ v3QueuesPath,
+ v3RunPath,
+ v3RunSpanPath,
+} from "~/utils/pathBuilder";
+
+const scope: TriggerUriScope = {
+ id: "env_1234",
+ slug: "prod",
+ project: { slug: "my-project-abcd", externalRef: "proj_abcdefghijklmnop" },
+ organization: { slug: "my-org-1234" },
+};
+
+const org = scope.organization;
+const project = scope.project;
+const env = { slug: scope.slug };
+
+const uriScope = { projectRef: project.externalRef, environmentId: scope.id };
+
+describe("resolveTriggerUri", () => {
+ it("resolves a run", () => {
+ const uri = formatTriggerUri({ kind: "run", ...uriScope, runId: "run_abc123" });
+ expect(resolveTriggerUri(scope, uri)).toEqual({
+ label: "run_abc123",
+ url: v3RunPath(org, project, env, { friendlyId: "run_abc123" }),
+ });
+ });
+
+ it("resolves a span to its run page with the span selected", () => {
+ const uri = formatTriggerUri({
+ kind: "span",
+ ...uriScope,
+ runId: "run_abc123",
+ spanId: "span_xyz",
+ });
+ const resolved = resolveTriggerUri(scope, uri);
+ expect(resolved).toEqual({
+ label: "run_abc123 (span_xyz)",
+ url: v3RunSpanPath(org, project, env, { friendlyId: "run_abc123" }, { spanId: "span_xyz" }),
+ });
+ expect(resolved!.url).toContain("span=span_xyz");
+ });
+
+ it("resolves an error group by fingerprint", () => {
+ const uri = formatTriggerUri({ kind: "error", ...uriScope, fingerprint: "error_5a1c73" });
+ expect(resolveTriggerUri(scope, uri)).toEqual({
+ label: "error_5a1c73",
+ url: v3ErrorPath(org, project, env, { fingerprint: "error_5a1c73" }),
+ });
+ });
+
+ it("resolves a queue to the queues list filtered to its name", () => {
+ const uri = formatTriggerUri({ kind: "queue", ...uriScope, name: "task/send email" });
+ expect(resolveTriggerUri(scope, uri)).toEqual({
+ label: "task/send email",
+ url: `${v3QueuesPath(org, project, env)}?query=task%2Fsend%20email`,
+ });
+ });
+
+ it("resolves a deployment by version", () => {
+ const uri = formatTriggerUri({ kind: "deployment", ...uriScope, version: "20260726.4" });
+ expect(resolveTriggerUri(scope, uri)).toEqual({
+ label: "20260726.4",
+ url: v3DeploymentVersionPath(org, project, env, "20260726.4"),
+ });
+ });
+
+ it("returns null for kinds with no dashboard page yet", () => {
+ expect(
+ resolveTriggerUri(scope, formatTriggerUri({ kind: "report", ...uriScope, key: "health" }))
+ ).toBeNull();
+ expect(
+ resolveTriggerUri(
+ scope,
+ formatTriggerUri({ kind: "investigation", ...uriScope, investigationId: "inv_1" })
+ )
+ ).toBeNull();
+ });
+
+ it("percent-decodes a segment before putting it in a path", () => {
+ const uri = formatTriggerUri({ kind: "deployment", ...uriScope, version: "2026.1+beta" });
+ expect(uri).toContain("2026.1%2Bbeta");
+ expect(resolveTriggerUri(scope, uri)!.label).toBe("2026.1+beta");
+ });
+
+ it("refuses a URI from another project or environment", () => {
+ const otherProject = formatTriggerUri({
+ kind: "run",
+ projectRef: "proj_somethingelse",
+ environmentId: scope.id,
+ runId: "run_abc123",
+ });
+ const otherEnvironment = formatTriggerUri({
+ kind: "run",
+ projectRef: project.externalRef,
+ environmentId: "env_other",
+ runId: "run_abc123",
+ });
+ expect(resolveTriggerUri(scope, otherProject)).toBeNull();
+ expect(resolveTriggerUri(scope, otherEnvironment)).toBeNull();
+ });
+
+ it("returns null instead of throwing on anything malformed", () => {
+ expect(resolveTriggerUri(scope, "")).toBeNull();
+ expect(resolveTriggerUri(scope, "https://cloud.trigger.dev/runs/run_abc")).toBeNull();
+ expect(resolveTriggerUri(scope, "trigger://proj_a/env_1234/teapot/x")).toBeNull();
+ expect(resolveTriggerUri(scope, "trigger://proj_abcdefghijklmnop/env_1234/run")).toBeNull();
+ });
+});
+
+describe("resolveTriggerUri: source URIs", () => {
+ const sha = "a".repeat(40);
+ const sourceUri = (path: string, line?: number) =>
+ formatTriggerUri({
+ kind: "source",
+ ...uriScope,
+ sha,
+ path,
+ ...(line === undefined ? {} : { line }),
+ });
+ const withRepo = (repository: TriggerUriScope["repository"]): TriggerUriScope => ({
+ ...scope,
+ repository,
+ });
+
+ it("opens the GitHub blob at the pinned commit, with the line", () => {
+ expect(
+ resolveTriggerUri(
+ withRepo({ fullName: "acme/orders" }),
+ sourceUri("src/tasks/send-order-receipt.ts", 42)
+ )
+ ).toEqual({
+ label: "src/tasks/send-order-receipt.ts:42",
+ url: `https://github.com/acme/orders/blob/${sha}/src/tasks/send-order-receipt.ts#L42`,
+ external: true,
+ });
+ });
+
+ it("omits the line fragment when there is no line, and encodes each path segment", () => {
+ expect(
+ resolveTriggerUri(withRepo({ fullName: "acme/orders" }), sourceUri("src/some dir/a file.ts"))
+ ?.url
+ ).toBe(`https://github.com/acme/orders/blob/${sha}/src/some%20dir/a%20file.ts`);
+ });
+
+ it("accepts a deployment's git remote however it was written", () => {
+ const expected = `https://github.com/acme/orders/blob/${sha}/src/a.ts`;
+ for (const remoteUrl of [
+ "https://github.com/acme/orders.git",
+ "git@github.com:acme/orders.git",
+ "ssh://git@github.com/acme/orders",
+ "https://x-access-token:secret@github.com/acme/orders.git",
+ ]) {
+ expect(resolveTriggerUri(withRepo({ remoteUrl }), sourceUri("src/a.ts"))?.url).toBe(expected);
+ }
+ });
+
+ it("returns null rather than guessing when there's no repository to open", () => {
+ expect(resolveTriggerUri(scope, sourceUri("src/a.ts"))).toBeNull();
+ expect(resolveTriggerUri(withRepo(null), sourceUri("src/a.ts"))).toBeNull();
+ expect(resolveTriggerUri(withRepo({ fullName: "" }), sourceUri("src/a.ts"))).toBeNull();
+ expect(
+ resolveTriggerUri(
+ withRepo({ remoteUrl: "https://gitlab.com/acme/orders" }),
+ sourceUri("src/a.ts")
+ )
+ ).toBeNull();
+ expect(
+ resolveTriggerUri(withRepo({ remoteUrl: "https://github.com/acme" }), sourceUri("src/a.ts"))
+ ).toBeNull();
+ expect(
+ resolveTriggerUri(withRepo({ fullName: "acme/orders/extra" }), sourceUri("src/a.ts"))
+ ).toBeNull();
+ });
+
+ it("still refuses a source URI from another project or environment", () => {
+ expect(
+ resolveTriggerUri(
+ withRepo({ fullName: "acme/orders" }),
+ formatTriggerUri({
+ kind: "source",
+ projectRef: "proj_somethingelse",
+ environmentId: scope.id,
+ sha,
+ path: "src/a.ts",
+ })
+ )
+ ).toBeNull();
+ });
+});
diff --git a/apps/webapp/test/routeCspImgSrc.test.ts b/apps/webapp/test/routeCspImgSrc.test.ts
new file mode 100644
index 00000000000..61f21b96569
--- /dev/null
+++ b/apps/webapp/test/routeCspImgSrc.test.ts
@@ -0,0 +1,163 @@
+// `withImgSrc` lets a route's own img-src win, so the document policy only protects
+// the other AI surfaces while no route sets a broader one. This scans literal policy
+// strings in the webapp sources โ it cannot see one assembled at runtime.
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const WEBAPP_ROOT = fileURLToPath(new URL("..", import.meta.url));
+const SCAN_ROOTS = ["app", "server.ts"];
+const SKIP_DIRS = new Set(["node_modules", "build", "dist", "coverage", ".turbo", ".cache"]);
+const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
+const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/;
+
+const CSP_HEADER_OWNER = "app/entry.server.tsx";
+
+/** Sources that resolve inside the document itself, so they carry nothing outward. */
+const LOCAL_SOURCES = new Set([
+ "'self'",
+ "'none'",
+ "data:",
+ "blob:",
+ "filesystem:",
+ "mediastream:",
+]);
+
+/**
+ * Hosts that serve content any stranger can upload. Necessarily incomplete โ the
+ * shape checks below are what actually holds the line.
+ */
+const PUBLIC_UPLOAD_HOSTS = [
+ "raw.githubusercontent.com",
+ "user-images.githubusercontent.com",
+ "gist.githubusercontent.com",
+ "objects.githubusercontent.com",
+ "camo.githubusercontent.com",
+ "imgur.com",
+ "cdn.discordapp.com",
+ "media.discordapp.net",
+ "s3.amazonaws.com",
+ "storage.googleapis.com",
+ "lh3.googleusercontent.com",
+ "blob.core.windows.net",
+ "pages.dev",
+ "vercel.app",
+ "netlify.app",
+ "ngrok.io",
+ "ngrok-free.app",
+ "trycloudflare.com",
+];
+
+const IMAGE_DIRECTIVES = new Set(["img-src", "default-src"]);
+const HAS_IMAGE_DIRECTIVE = /(^|;)\s*(img-src|default-src)\s+\S/i;
+const STRING_LITERAL = /"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)'|`((?:[^`\\]|\\.)*)`/g;
+
+function hostOf(source: string): string | undefined {
+ const withoutScheme = source.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
+ const host = withoutScheme.split("/")[0]?.split(":")[0];
+ return host && host.includes(".") ? host.toLowerCase() : undefined;
+}
+
+/** Why this source would let an image request carry data off-origin, if it would. */
+function overBroadReason(source: string): string | undefined {
+ if (LOCAL_SOURCES.has(source.toLowerCase())) return undefined;
+ if (source === "*" || source.includes("*")) {
+ return "wildcard matches hosts nobody vetted";
+ }
+ if (/^[a-z][a-z0-9+.-]*:$/i.test(source)) {
+ return "a bare scheme allows every host on it";
+ }
+ const host = hostOf(source);
+ if (!host) return undefined;
+ const match = PUBLIC_UPLOAD_HOSTS.find((h) => host === h || host.endsWith(`.${h}`));
+ return match ? `${match} accepts uploads from anyone` : undefined;
+}
+
+/** Every over-broad image source in a policy string, as `directive source: reason`. */
+function overBroadImageSources(policy: string): string[] {
+ const findings: string[] = [];
+
+ for (const segment of policy.split(";")) {
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
+ const [directive, ...sources] = tokens;
+ if (!directive || !IMAGE_DIRECTIVES.has(directive.toLowerCase())) continue;
+
+ for (const source of sources) {
+ const reason = overBroadReason(source);
+ if (reason) findings.push(`${directive} ${source}: ${reason}`);
+ }
+ }
+
+ return findings;
+}
+
+function collectSourceFiles(): string[] {
+ const files: string[] = [];
+
+ const walk = (absolute: string) => {
+ const stats = statSync(absolute);
+ if (stats.isFile()) {
+ if (SOURCE_FILE.test(absolute) && !TEST_FILE.test(absolute)) files.push(absolute);
+ return;
+ }
+ for (const entry of readdirSync(absolute, { withFileTypes: true })) {
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
+ walk(join(absolute, entry.name));
+ }
+ };
+
+ for (const root of SCAN_ROOTS) walk(join(WEBAPP_ROOT, root));
+ return files;
+}
+
+const sourceFiles = collectSourceFiles().map((absolute) => ({
+ path: relative(WEBAPP_ROOT, absolute).replaceAll("\\", "/"),
+ contents: readFileSync(absolute, "utf8"),
+}));
+
+function stringLiteralsIn(contents: string): string[] {
+ return [...contents.matchAll(STRING_LITERAL)].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
+}
+
+describe("route-level image CSP", () => {
+ it("scans the webapp sources", () => {
+ expect(sourceFiles.length).toBeGreaterThan(500);
+ expect(sourceFiles.map((f) => f.path)).toContain(CSP_HEADER_OWNER);
+ expect(sourceFiles.map((f) => f.path)).toContain("server.ts");
+ });
+
+ it("recognises the over-broad shapes", () => {
+ expect(overBroadImageSources("img-src *")).toHaveLength(1);
+ expect(overBroadImageSources("img-src 'self' https:")).toHaveLength(1);
+ expect(overBroadImageSources("img-src https://*.example.com")).toHaveLength(1);
+ expect(overBroadImageSources("default-src * ; img-src 'self'")).toHaveLength(1);
+ expect(overBroadImageSources("img-src https://raw.githubusercontent.com")).toHaveLength(1);
+ expect(
+ overBroadImageSources("frame-ancestors *; img-src 'self' data: blob: https://a.example.com")
+ ).toEqual([]);
+ });
+
+ it("finds no over-broad image policy in any source", () => {
+ const findings: string[] = [];
+
+ for (const file of sourceFiles) {
+ for (const literal of stringLiteralsIn(file.contents)) {
+ if (!HAS_IMAGE_DIRECTIVE.test(literal)) continue;
+ for (const finding of overBroadImageSources(literal)) {
+ findings.push(`${file.path}: ${finding}`);
+ }
+ }
+ }
+
+ expect(findings).toEqual([]);
+ });
+
+ it("sets the Content-Security-Policy header in one place only", () => {
+ const setters = sourceFiles
+ .filter((file) => /["']Content-Security-Policy["']/i.test(file.contents))
+ .map((file) => file.path);
+
+ expect(setters).toEqual([CSP_HEADER_OWNER]);
+ });
+});
diff --git a/apps/webapp/test/tenantContextFromAuthEnvironment.test.ts b/apps/webapp/test/tenantContextFromAuthEnvironment.test.ts
index 163338c0728..460bd74b370 100644
--- a/apps/webapp/test/tenantContextFromAuthEnvironment.test.ts
+++ b/apps/webapp/test/tenantContextFromAuthEnvironment.test.ts
@@ -45,4 +45,18 @@ describe("tenantContextFromAuthEnvironment", () => {
it("does not propagate impersonating (auth environments are real, not impersonated)", () => {
expect(tenantContextFromAuthEnvironment(envWithOrgMember).impersonating).toBeUndefined();
});
+
+ it("prefers the JWT actor over orgMember", () => {
+ const ctx = tenantContextFromAuthEnvironment(envWithOrgMember, { sub: "usr_99" });
+ expect(ctx.userId).toBe("usr_99");
+ });
+
+ it("attributes a shared env with no orgMember to the JWT actor", () => {
+ const ctx = tenantContextFromAuthEnvironment(envWithoutOrgMember, { sub: "usr_99" });
+ expect(ctx.userId).toBe("usr_99");
+ });
+
+ it("falls back to orgMember when there is no actor", () => {
+ expect(tenantContextFromAuthEnvironment(envWithOrgMember, undefined).userId).toBe("usr_42");
+ });
});
diff --git a/apps/webapp/test/uatEnvironmentClaim.test.ts b/apps/webapp/test/uatEnvironmentClaim.test.ts
new file mode 100644
index 00000000000..a83632e9f6a
--- /dev/null
+++ b/apps/webapp/test/uatEnvironmentClaim.test.ts
@@ -0,0 +1,323 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * A user-actor token minted for one environment must not be honoured against another, even when
+ * its user is a member of both. These drive each UAT-accepting route with a real token through the
+ * real preamble and the real environment resolution; only the database and the RBAC plugin are stubbed.
+ */
+
+const { SESSION_SECRET } = vi.hoisted(() => ({
+ SESSION_SECRET: "test-session-secret-for-uat-environment-claim",
+}));
+
+const mocks = vi.hoisted(() => ({
+ can: vi.fn<(...args: any[]) => boolean>(),
+ resolveRunCommit: vi.fn<(...args: any[]) => Promise>(),
+ resolveDashboardAgentRepoSnapshot: vi.fn<(...args: any[]) => Promise>(),
+ findCurrentWorkerFromEnvironment: vi.fn<(...args: any[]) => Promise