Files
2026-07-13 13:32:57 +08:00

259 lines
8.4 KiB
TypeScript

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<ReturnType<SessionListPresenter["call"]>>;
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,
};
}
}