259 lines
8.4 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|