import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { chatExists, countUserMessages, createChat, getChatMessages, getSession, listChatIdsWithOpenInvestigations, listChats, renameChat, setChatPinned, softDeleteChat, } from "@internal/dashboard-agent-db"; import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic"; import type { UIMessage } from "ai"; import { z } from "zod"; import { checkMessageParts, declaredBodyBytes, exceedsMessageBodyBytes, MESSAGE_TOO_LARGE_CODE, MESSAGE_TOO_LARGE_ERROR, } from "~/components/dashboard-agent/message-limits"; import { MAX_URIS_PER_RESOLVE_REQUEST } from "~/components/dashboard-agent/resolve-uris"; import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { dashboardAgentApiOrigin, isDashboardAgentConfigured, mintDashboardAgentToken, mintDashboardAgentUserActorToken, resolveDashboardAgentRepoSnapshot, startDashboardAgentSession, } from "~/services/dashboardAgent.server"; import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server"; import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server"; import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; import { logger } from "~/services/logger.server"; import { resolveTriggerUri } from "~/services/resolveTriggerUri.server"; import { requireUser } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server"; // The client-metadata whitelist lives with the `in` proxy, the other mint site, so the two cannot // drift apart. import { pickAgentClientMetadata } from "./resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$"; const ActionBody = z.object({ intent: z.enum([ "start", "create", "token", "rename", "pin", "delete", "resolve", "resolve-many", ]), // Omitted for `create` (the server generates it); required for the rest. chatId: z.string().min(1).optional(), // The first user message (JSON UIMessage), for `create`. message: z.string().optional(), clientData: z.string().optional(), title: z.string().optional(), pinned: z.enum(["true", "false"]).optional(), // A `trigger://` URI, for `resolve`. uri: z.string().optional(), // A JSON array of `trigger://` URIs, for `resolve-many`. uris: z.string().optional(), }); // History list by default. `?chatId=` returns the stored transcript plus session, // `?quota=1` the message count. export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const userId = user.id; const { organizationSlug, projectParam } = EnvironmentParamSchema.parse(params); if ( !(await canAccessDashboardAgent({ userId, isAdmin: user.admin, isImpersonating: user.isImpersonating, organizationSlug, })) ) { return json({ error: "Not found" }, { status: 404 }); } const searchParams = new URL(request.url).searchParams; const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) return json({ error: "Project not found" }, { status: 404 }); // The open chat is excluded and counted from the live transcript instead, so an // unpersisted turn still counts against the cap. if (searchParams.get("quota") === "1") { const used = await countUserMessages(dashboardAgentDb, { organizationId: project.organizationId, userId, excludeChatId: searchParams.get("chatId") ?? undefined, }); return json({ used }); } const chatId = searchParams.get("chatId"); if (chatId) { const [messages, session] = await Promise.all([ getChatMessages(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }), getSession(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }), ]); return json({ messages: messages ?? [], session }); } const chats = await listChats(dashboardAgentDb, { organizationId: project.organizationId, userId, }); // One query for all the listed chats, never one per row. const investigatingChatIds = await listChatIdsWithOpenInvestigations(dashboardAgentDb, { organizationId: project.organizationId, userId, }); return json({ chats: chats.map((chat) => ({ ...chat, hasOpenInvestigation: investigatingChatIds.has(chat.id), })), }); }; /** Only a source URI needs the connected repository, so a batch without one skips the read. */ async function findRepositoryForSourceUris(projectId: string, uris: string[]) { if (!uris.some((uri) => uri.includes("/source/"))) return null; const connected = await $replica.connectedGithubRepository.findFirst({ where: { projectId, repository: { installation: { deletedAt: null, suspendedAt: null } }, }, select: { repository: { select: { fullName: true } } }, }); return connected?.repository ?? null; } function messageTooLarge() { return json({ error: MESSAGE_TOO_LARGE_ERROR, code: MESSAGE_TOO_LARGE_CODE }, { status: 413 }); } export const action = async ({ request, params }: ActionFunctionArgs) => { const user = await requireUser(request); const userId = user.id; const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); if ( !(await canAccessDashboardAgent({ userId, isAdmin: user.admin, isImpersonating: user.isImpersonating, organizationSlug, })) ) { return json({ error: "Not found" }, { status: 404 }); } // A declared oversize is refused here, before any lookup. Without a content-length the // ingress cap has already ended the request mid-stream, so this never sees it. if (exceedsMessageBodyBytes(declaredBodyBytes(request.headers))) { return messageTooLarge(); } const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) return json({ error: "Project not found" }, { status: 404 }); const parsed = ActionBody.safeParse(Object.fromEntries(await request.formData())); if (!parsed.success) return json({ error: "Invalid request" }, { status: 400 }); // The server generates the chat id, so a client can never name another user's chat. if (parsed.data.intent === "create") { if (!isDashboardAgentConfigured()) { return json({ error: "The dashboard agent is not configured." }, { status: 501 }); } let firstMessage: UIMessage | undefined; try { firstMessage = parsed.data.message ? (JSON.parse(parsed.data.message) as UIMessage) : undefined; } catch { return json({ error: "Invalid message" }, { status: 400 }); } if (!firstMessage) return json({ error: "message is required" }, { status: 400 }); // A body under the byte cap can still be one huge part or hundreds of small ones. if ( exceedsMessageBodyBytes(Buffer.byteLength(parsed.data.message ?? "", "utf8")) || checkMessageParts(firstMessage.parts) !== null ) { return messageTooLarge(); } let clientData: Record | undefined; try { clientData = parsed.data.clientData ? (JSON.parse(parsed.data.clientData) as Record) : undefined; } catch { /* invalid JSON — create without context metadata */ } // Only the whitelisted page context survives; the rest is injected below. const clientContext = pickAgentClientMetadata(clientData); // Membership-scoped: dev rows are per-developer, so a token must never be minted for // someone else's environment — or, when nothing resolves, for no environment at all. const runtimeEnv = await findEnvironmentBySlug(project.id, envParam, userId); if (!runtimeEnv) return json({ error: "Environment not found" }, { status: 404 }); const environmentAddress = dashboardAgentEnvironmentAddress(runtimeEnv); const chatId = generateFriendlyId("chat"); try { const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id); const headStarted = Boolean(env.ANTHROPIC_API_KEY); // The lookups and the mint all run before the chat row exists, so a failure here can't // leave an empty chat behind in the user's history. const headStartMetadata = headStarted ? { // The agent validates run metadata against its clientDataSchema, so the // per-turn client context must accompany the injected auth and context fields. ...clientContext, userActorToken: await mintDashboardAgentUserActorToken(userId, { environmentId: runtimeEnv.id, }), apiOrigin: dashboardAgentApiOrigin(), projectRef: project.externalRef, // Server-owned, like the `in` proxy: the eval opt-out and every tenancy check // key on these, so the client can't set them at all. organizationId: project.organizationId, userId, projectId: project.id, // Same environment identity the `in` proxy injects. environmentId: runtimeEnv.id, ...environmentAddress, ...(repoSnapshot ? { repoSnapshot } : {}), } : undefined; await createChat(dashboardAgentDb, { id: chatId, organizationId: project.organizationId, userId, ...(clientData ? { metadata: { context: clientContext } } : {}), }); try { if (headStartMetadata) { // Injects the delegated token and context into the run's payload server-side. await startDashboardAgentHeadStart({ chatId, messages: [firstMessage], mode: repoSnapshot ? "code" : "assistant", metadata: headStartMetadata, }); } else { // Cold start: the client sends the first message through the `in` proxy, which // injects the token. // Same server-owned identity the head-start path injects; the `in` proxy adds the // delegated token on the first turn. await startDashboardAgentSession({ chatId, clientData: { ...clientContext, organizationId: project.organizationId, userId, projectId: project.id, environmentId: runtimeEnv.id, ...environmentAddress, }, }); } } catch (error) { // Both starts are one create-session-and-trigger round trip, so a rejection means no // handover was dispatched and no message was sent: a session the call did create in // spite of the error idles out having done nothing. The empty row is all there is to undo. // Swallowed so the start's own error is what surfaces and gets logged. await softDeleteChat(dashboardAgentDb, { chatId, userId }).catch((cleanupError) => { logger.error("Failed to remove a dashboard agent chat whose start failed", { chatId, error: cleanupError, }); }); throw error; } let publicAccessToken: string; try { publicAccessToken = await mintDashboardAgentToken(chatId); } catch (error) { // The start resolved, so the session is live and a head start is already streaming into // it. Deleting the chat here would hide a running agent; the client can ask for a token // again through the `token` intent. logger.error("Dashboard agent chat started but its token mint failed", { chatId, error }); return json( { error: "The dashboard agent started but couldn't be opened. Try opening it again." }, { status: 500 } ); } return json({ chatId, publicAccessToken, headStarted }); } catch (error) { logger.error("Failed to create dashboard agent chat", { chatId, error }); return json( { error: "The dashboard agent couldn't start. Please try again in a moment." }, { status: 500 } ); } } // Scoped by the environment in the URL: the resolver refuses a URI naming a // different project or environment. if (parsed.data.intent === "resolve") { const uri = parsed.data.uri; if (!uri) return json({ error: "uri is required" }, { status: 400 }); const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) return json({ error: "Environment not found" }, { status: 404 }); const repository = await findRepositoryForSourceUris(project.id, [uri]); const resolved = resolveTriggerUri({ ...environment, repository }, uri); if (!resolved) return json({ error: "Nothing to open for that link" }, { status: 404 }); return json({ path: resolved.url, label: resolved.label, external: resolved.external ?? false, }); } // The card's citations in one request: one environment lookup and one repo lookup for the // whole batch, same environment scope as `resolve`. if (parsed.data.intent === "resolve-many") { let uris: string[]; try { const list = JSON.parse(parsed.data.uris ?? "") as unknown; if (!Array.isArray(list) || list.some((uri) => typeof uri !== "string")) { return json({ error: "uris is required" }, { status: 400 }); } uris = [...new Set(list as string[])]; } catch { return json({ error: "uris is required" }, { status: 400 }); } if (uris.length === 0) return json({ error: "uris is required" }, { status: 400 }); if (uris.length > MAX_URIS_PER_RESOLVE_REQUEST) { return json({ error: "Too many links in one request" }, { status: 400 }); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) return json({ error: "Environment not found" }, { status: 404 }); const repository = await findRepositoryForSourceUris(project.id, uris); const scope = { ...environment, repository }; // A null entry is the definitive "nothing to open": the client caches it. const resolved: Record = {}; for (const uri of uris) { const hit = resolveTriggerUri(scope, uri); resolved[uri] = hit ? { path: hit.url, label: hit.label, external: hit.external ?? false } : null; } return json({ resolved }); } const { intent, chatId } = parsed.data; if (!chatId) return json({ error: "chatId is required" }, { status: 400 }); switch (intent) { case "start": { if (!isDashboardAgentConfigured()) { return json({ error: "The dashboard agent is not configured." }, { status: 501 }); } // Resume only, so a client-supplied chatId is checked against the caller first. if ( !(await chatExists(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId, })) ) { return json({ error: "Chat not found" }, { status: 404 }); } let clientData: Record | undefined; try { clientData = parsed.data.clientData ? (JSON.parse(parsed.data.clientData) as Record) : undefined; } catch { /* invalid JSON — start without metadata */ } try { const { publicAccessToken } = await startDashboardAgentSession({ chatId, clientData }); return json({ publicAccessToken }); } catch (error) { logger.error("Failed to start dashboard agent session", { chatId, error }); return json( { error: "The dashboard agent couldn't start. Please try again in a moment." }, { status: 500 } ); } } case "token": { if (!isDashboardAgentConfigured()) { return json({ error: "The dashboard agent is not configured." }, { status: 501 }); } // Only mint a token for a chat the caller owns. if ( !(await chatExists(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId, })) ) { return json({ error: "Chat not found" }, { status: 404 }); } return json({ token: await mintDashboardAgentToken(chatId) }); } case "rename": { if (!parsed.data.title) return json({ error: "title is required" }, { status: 400 }); await renameChat(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId, title: parsed.data.title, }); return json({ ok: true }); } case "pin": { await setChatPinned(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId, pinned: parsed.data.pinned === "true", }); return json({ ok: true }); } case "delete": { // `softDeleteChat` is owner-scoped but takes no org, so the org scope has to be // enforced here. if ( !(await chatExists(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId, })) ) { return json({ error: "Chat not found" }, { status: 404 }); } await softDeleteChat(dashboardAgentDb, { chatId, userId }); return json({ ok: true }); } } };