import { type Span } from "@opentelemetry/api"; import { type ClickHouse } from "@internal/clickhouse"; import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; import { type SessionStatus, SessionsRepository, LEGACY_PLAYGROUND_TAG, } from "~/services/sessionsRepository/sessionsRepository.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { runStore } from "~/v3/runStore.server"; import { startActiveSpan } from "~/v3/tracer.server"; export type SessionListOptions = { userId?: string; projectId: string; // filters types?: string[]; taskIdentifiers?: string[]; externalId?: string; tags?: string[]; statuses?: SessionStatus[]; period?: string; from?: number; to?: number; // pagination direction?: Direction; cursor?: string; pageSize?: number; }; const DEFAULT_PAGE_SIZE = 25; export type SessionList = Awaited>; export type SessionListItem = SessionList["sessions"][0]; export type SessionListAppliedFilters = SessionList["filters"]; export class SessionListPresenter { constructor( private readonly replica: PrismaClientOrTransaction, private readonly clickhouse: ClickHouse ) {} public async call(organizationId: string, environmentId: string, options: SessionListOptions) { return startActiveSpan( "SessionListPresenter.call", (span) => this.#call(organizationId, environmentId, options, span), { attributes: { organizationId, environmentId, projectId: options.projectId, }, } ); } async #call( organizationId: string, environmentId: string, { userId, projectId, types, taskIdentifiers, externalId, tags, statuses, period, from, to, direction = "forward", cursor, pageSize = DEFAULT_PAGE_SIZE, }: SessionListOptions, rootSpan: Span ) { const time = timeFilters({ period, from, to }); const hasFilters = (types !== undefined && types.length > 0) || (taskIdentifiers !== undefined && taskIdentifiers.length > 0) || (externalId !== undefined && externalId !== "") || (tags !== undefined && tags.length > 0) || (statuses !== undefined && statuses.length > 0) || !time.isDefault; rootSpan.setAttribute("filters.hasFilters", hasFilters); rootSpan.setAttribute("page.size", pageSize); if (cursor) rootSpan.setAttribute("page.cursor", cursor); const displayableEnvironment = await startActiveSpan( "SessionListPresenter.findDisplayableEnvironment", () => findDisplayableEnvironment(environmentId, userId) ); if (!displayableEnvironment) { throw new ServiceValidationError("No environment found"); } const sessionsRepository = new SessionsRepository({ clickhouse: this.clickhouse, prisma: this.replica as PrismaClient, }); function clampToNow(date: Date): Date { const now = new Date(); return date > now ? now : date; } // Sessions are produced by chat.agent() tasks. The TaskIdentifier // registry has historically misclassified agent slugs as STANDARD (the // toTriggerSource helper didn't know about "agent" until recently), so // read from BackgroundWorkerTask of the current worker instead — same // source AgentListPresenter uses for the Agents page. const possibleTasksAsync = startActiveSpan( "SessionListPresenter.getPossibleTasks", async () => { const currentWorker = await findCurrentWorkerFromEnvironment( { id: environmentId, type: displayableEnvironment.type }, this.replica ); if (!currentWorker) return []; const agents = await this.replica.backgroundWorkerTask.findMany({ where: { workerId: currentWorker.id, triggerSource: "AGENT" }, select: { slug: true }, orderBy: { slug: "asc" }, }); return agents.map((a) => ({ slug: a.slug, isInLatestDeployment: true })); } ); const { sessions, pagination } = await sessionsRepository.listSessions({ organizationId, projectId, environmentId, types, taskIdentifiers, externalId, tags, statuses, period, from: time.from ? time.from.getTime() : undefined, to: time.to ? clampToNow(time.to).getTime() : undefined, page: { size: pageSize, cursor, direction, }, }); rootSpan.setAttribute("page.count", sessions.length); let hasAnySessions = sessions.length > 0; if (!hasAnySessions) { const firstSession = await startActiveSpan("SessionListPresenter.hasAnySessions", () => this.replica.session.findFirst({ where: { runtimeEnvironmentId: environmentId }, select: { id: true }, }) ); if (firstSession) { hasAnySessions = true; } } // Resolve current-run friendlyIds in one query so each row can link to // its live run. Status is intentionally not joined yet — that lives in // ClickHouse and would mean a second query per page; the link itself // is the value most viewers want first. const currentRunIds = sessions .map((s) => s.currentRunId) .filter((id): id is string => Boolean(id)); const possibleTasks = await possibleTasksAsync; const currentRuns = await startActiveSpan( "SessionListPresenter.findCurrentRuns", async (span) => { span.setAttribute("currentRunIds.count", currentRunIds.length); // Scope by projectId + runtimeEnvironmentId — Session.currentRunId // is a plain string column without an FK, so a stale or corrupted // pointer could surface another tenant's run. The list query above // is already env-scoped; the run lookup needs the same fence. return currentRunIds.length > 0 ? runStore.findRuns( { where: { id: { in: currentRunIds }, projectId, runtimeEnvironmentId: environmentId, }, select: { id: true, friendlyId: true }, }, this.replica ) : []; } ); const runById = new Map(currentRuns.map((r) => [r.id, r] as const)); const now = Date.now(); return { sessions: sessions.map((session) => { const status: SessionStatus = session.closedAt != null ? "CLOSED" : session.expiresAt != null && session.expiresAt.getTime() < now ? "EXPIRED" : "ACTIVE"; const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined; return { id: session.id, friendlyId: session.friendlyId, externalId: session.externalId, type: session.type, taskIdentifier: session.taskIdentifier, isTest: session.isTest, // Hide the legacy "playground" tag (pre-isTest sessions) from display. tags: session.tags ? [...session.tags] .filter((t) => t !== LEGACY_PLAYGROUND_TAG) .sort((a, b) => a.localeCompare(b)) : [], status, closedAt: session.closedAt ? session.closedAt.toISOString() : undefined, closedReason: session.closedReason ?? undefined, expiresAt: session.expiresAt ? session.expiresAt.toISOString() : undefined, createdAt: session.createdAt.toISOString(), updatedAt: session.updatedAt.toISOString(), environment: displayableEnvironment, currentRunFriendlyId: currentRun?.friendlyId, }; }), pagination: { next: pagination.nextCursor ?? undefined, previous: pagination.previousCursor ?? undefined, }, filters: { types: types ?? [], taskIdentifiers: taskIdentifiers ?? [], externalId, tags: tags ?? [], statuses: statuses ?? [], from: time.from, to: time.to, }, possibleTasks, hasFilters, hasAnySessions, }; } }