chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,25 @@
import type { PrismaClient, User } from "@trigger.dev/database";
import { prisma } from "~/db.server";
export class NewOrganizationPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId }: { userId: User["id"] }) {
const organizations = await this.#prismaClient.organization.findMany({
select: {
projects: {
where: { deletedAt: null },
},
},
where: { members: { some: { userId } } },
});
return {
hasOrganizations: organizations.filter((o) => o.projects.length > 0).length > 0,
};
}
}
@@ -0,0 +1,270 @@
import type { RuntimeEnvironment, PrismaClient } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { type UserFromSession } from "~/services/session.server";
import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder";
import { SelectBestEnvironmentPresenter } from "./SelectBestEnvironmentPresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { env } from "~/env.server";
import { flags } from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
user,
organizationSlug,
projectSlug,
environmentSlug,
request,
}: {
user: UserFromSession;
organizationSlug: string;
projectSlug: string | undefined;
environmentSlug: string | undefined;
request: Request;
}) {
const organizations = await this.#getOrganizations(user.id);
if (organizations.length === 0) {
logger.info("No organizations", {
organizationSlug,
request,
});
throw redirect(newOrganizationPath());
}
const organization = organizations.find((o) => o.slug === organizationSlug);
if (!organization) {
logger.info("Not Found: organization", {
organizationSlug,
request,
organization,
});
throw new Response("Organization not Found", { status: 404 });
}
const selector = new SelectBestEnvironmentPresenter();
const bestProject = await selector.selectBestProjectFromProjects({
user,
projectSlug,
projects: organization.projects,
});
if (!bestProject) {
logger.info("Not Found: project", {
projectSlug,
request,
project: bestProject,
});
throw redirect(newProjectPath(organization));
}
const fullProject = await this.#prismaClient.project.findFirst({
where: {
id: bestProject.id,
},
include: {
environments: {
select: {
id: true,
type: true,
slug: true,
paused: true,
pauseSource: true,
isBranchableEnvironment: true,
branchName: true,
parentEnvironmentId: true,
archivedAt: true,
updatedAt: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
});
if (!fullProject) {
logger.info("Not Found: project", {
projectSlug,
request,
project: bestProject,
});
throw redirect(newProjectPath(organization));
}
const environments = fullProject.environments.filter(
(env) => env.type !== "DEVELOPMENT" || env.orgMember?.userId === user.id
);
const environmentsWithActivity = await hydrateEnvsWithActivity(
user.id,
fullProject.id,
environments
);
const environment = this.#getEnvironment({
user,
projectId: fullProject.id,
environments,
environmentSlug,
});
return {
organizations,
organization,
project: {
...fullProject,
createdAt: fullProject.createdAt,
environments: sortEnvironments(environmentsWithActivity),
},
environment,
};
}
async #getOrganizations(userId: string) {
const orgs = await this.#prismaClient.organization.findMany({
where: { members: { some: { userId } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
slug: true,
title: true,
avatar: true,
featureFlags: true,
projects: {
where: { deletedAt: null, version: "V3" },
select: {
id: true,
slug: true,
name: true,
updatedAt: true,
externalRef: true,
},
orderBy: { name: "asc" },
},
_count: {
select: {
members: true,
},
},
},
});
// Get global feature flags with env-var-based defaults
const globalFlags = await flags({
defaultValues: {
hasAiAccess: env.AI_FEATURES_ENABLED === "1",
hasDashboardAgentAccess: env.DASHBOARD_AGENT_ENABLED === "1",
hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1",
},
});
return orgs.map((org) => {
const orgFlagsResult = org.featureFlags
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
: ({ success: false } as const);
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
// Combine global flags with org flags (org flags win)
const combinedFlags = { ...globalFlags, ...orgFlags };
return {
id: org.id,
slug: org.slug,
title: org.title,
avatar: parseAvatar(org.avatar, defaultAvatar),
featureFlags: combinedFlags,
projects: org.projects.map((project) => ({
id: project.id,
slug: project.slug,
name: project.name,
updatedAt: project.updatedAt,
externalRef: project.externalRef,
})),
membersCount: org._count.members,
};
});
}
#getEnvironment({
user,
projectId,
environmentSlug,
environments,
}: {
user: UserFromSession;
projectId: string;
environmentSlug: string | undefined;
environments: (Pick<
RuntimeEnvironment,
| "id"
| "slug"
| "type"
| "branchName"
| "paused"
| "pauseSource"
| "parentEnvironmentId"
| "isBranchableEnvironment"
| "archivedAt"
> & {
orgMember: null | {
userId: string | undefined;
};
})[];
}) {
if (environmentSlug) {
const env = environments.find(
(e) =>
e.slug === environmentSlug &&
(e.type !== "DEVELOPMENT" || e.orgMember?.userId === user.id)
);
if (env) {
return env;
}
if (!env) {
logger.info("Not Found: environment", {
environmentSlug,
environments,
});
}
}
const currentEnvironmentId: string | undefined =
user.dashboardPreferences.projects[projectId]?.currentEnvironment.id;
const environment = environments.find((e) => e.id === currentEnvironmentId);
if (environment) {
return environment;
}
//otherwise show their dev environment
const yourDevEnvironment = environments.find(
(env) =>
env.type === "DEVELOPMENT" &&
env.parentEnvironmentId === null &&
env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
}
//otherwise show their prod environment
const prodEnvironment = environments.find((env) => env.type === "PRODUCTION");
if (prodEnvironment) {
return prodEnvironment;
}
throw new Error("No environments found");
}
}
@@ -0,0 +1,77 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import type { Project } from "~/models/project.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import type { User } from "~/models/user.server";
import { sortEnvironments } from "~/utils/environmentSort";
export class ProjectPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
id,
}: Pick<Project, "id"> & {
userId: User["id"];
}) {
const project = await this.#prismaClient.project.findFirst({
select: {
id: true,
slug: true,
name: true,
organizationId: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
version: true,
externalRef: true,
environments: {
select: {
id: true,
slug: true,
type: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
apiKey: true,
},
},
},
where: { id, deletedAt: null, organization: { members: { some: { userId } } } },
});
if (!project) {
return undefined;
}
return {
id: project.id,
slug: project.slug,
ref: project.externalRef,
name: project.name,
organizationId: project.organizationId,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
deletedAt: project.deletedAt,
version: project.version,
environments: sortEnvironments(
project.environments.map((environment) => ({
...displayableEnvironment(environment, userId),
userId: environment.orgMember?.user.id,
}))
),
};
}
}
@@ -0,0 +1,64 @@
import { type TaskRunStatus } from "@trigger.dev/database";
import {
getRunFiltersFromSearchParams,
TaskRunListSearchFilters,
} from "~/components/runs/v3/RunFilters";
import { getRootOnlyFilterPreference } from "~/services/preferences/uiPreferences.server";
import { type ParsedRunFilters } from "~/services/runsRepository/runsRepository.server";
type FiltersFromRequest = ParsedRunFilters & Required<Pick<ParsedRunFilters, "rootOnly">>;
export async function getRunFiltersFromRequest(request: Request): Promise<FiltersFromRequest> {
const url = new URL(request.url);
let rootOnlyValue = false;
if (url.searchParams.has("rootOnly")) {
rootOnlyValue = url.searchParams.get("rootOnly") === "true";
} else {
rootOnlyValue = await getRootOnlyFilterPreference(request);
}
const s = getRunFiltersFromSearchParams(url.searchParams);
const {
tasks,
versions,
statuses,
tags,
period,
bulkId,
from,
to,
cursor,
direction,
runId,
batchId,
scheduleId,
queues,
regions,
machines,
errorId,
sources,
} = TaskRunListSearchFilters.parse(s);
return {
tasks,
versions,
statuses: statuses as TaskRunStatus[] | undefined,
tags,
period,
bulkId,
from,
to,
batchId,
runId,
scheduleId,
rootOnly: rootOnlyValue,
direction: direction,
cursor: cursor,
queues,
regions,
machines,
errorId,
sources,
};
}
@@ -0,0 +1,182 @@
import {
type RuntimeEnvironment,
type PrismaClient,
type RuntimeEnvironmentType,
} from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { type UserFromSession } from "~/services/session.server";
export type MinimumEnvironment = Pick<RuntimeEnvironment, "id" | "type" | "slug" | "paused"> & {
orgMember: null | {
userId: string | undefined;
};
};
export class SelectBestEnvironmentPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ user }: { user: UserFromSession }) {
const { project, organization } = await this.getBestProject(user);
const environment = await this.selectBestEnvironment(project.id, user, project.environments);
return {
project,
organization,
environment,
};
}
async getBestProject(user: UserFromSession) {
//try get current project from cookie
const projectId = user.dashboardPreferences.currentProjectId;
if (projectId) {
const project = await this.#prismaClient.project.findFirst({
where: {
id: projectId,
deletedAt: null,
organization: { members: { some: { userId: user.id } } },
},
include: {
organization: true,
environments: {
select: {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
paused: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
});
if (project) {
return { project, organization: project.organization };
}
}
//failing that, we pick the most recently modified project
const projects = await this.#prismaClient.project.findMany({
include: {
organization: true,
environments: {
select: {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
paused: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
deletedAt: null,
organization: {
members: { some: { userId: user.id } },
},
},
orderBy: {
updatedAt: "desc",
},
take: 1,
});
if (projects.length === 0) {
throw new Response("Not Found", { status: 404 });
}
return { project: projects[0], organization: projects[0].organization };
}
async selectBestProjectFromProjects({
user,
projectSlug,
projects,
}: {
user: UserFromSession;
projectSlug: string | undefined;
projects: {
id: string;
slug: string;
name: string;
updatedAt: Date;
}[];
}) {
if (projectSlug) {
const proj = projects.find((p) => p.slug === projectSlug);
if (proj) {
return proj;
}
if (!proj) {
logger.info("Not Found: project", {
projectSlug,
projects,
});
}
}
const currentProjectId = user.dashboardPreferences.currentProjectId;
const project = projects.find((p) => p.id === currentProjectId);
if (project) {
return project;
}
//most recently updated
return projects.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).at(0);
}
async selectBestEnvironment<
T extends {
id: string;
type: RuntimeEnvironmentType;
slug: string;
parentEnvironmentId: string | null;
orgMember: { userId: string } | null;
},
>(projectId: string, user: UserFromSession, environments: T[]): Promise<T> {
//try get current environment from prefs
const currentEnvironmentId: string | undefined =
user.dashboardPreferences.projects[projectId]?.currentEnvironment.id;
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
if (currentEnvironment) {
return currentEnvironment;
}
//otherwise show their dev environment
const yourDevEnvironment = environments.find(
// Return the default dev environment (the root, no parent), not a branch
(env) =>
env.type === "DEVELOPMENT" &&
env.parentEnvironmentId === null &&
env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
}
//otherwise show their prod environment
const prodEnvironment = environments.find((env) => env.type === "PRODUCTION");
if (prodEnvironment) {
return prodEnvironment;
}
throw new Error("No environments found");
}
}
@@ -0,0 +1,16 @@
import type { SessionListSearchFilters } from "~/components/sessions/v1/SessionFilters";
import { getSessionFiltersFromSearchParams } from "~/components/sessions/v1/SessionFilters";
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
export type SessionFiltersFromRequest = SessionListSearchFilters & {
statuses?: SessionStatus[];
};
export function getSessionFiltersFromRequest(request: Request): SessionFiltersFromRequest {
const url = new URL(request.url);
const s = getSessionFiltersFromSearchParams(url.searchParams);
return {
...s,
statuses: s.statuses as SessionStatus[] | undefined,
};
}
@@ -0,0 +1,66 @@
import { getTeamMembersAndInvites } from "~/models/member.server";
import { rbac } from "~/services/rbac.server";
import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server";
import { BasePresenter } from "./v3/basePresenter.server";
export class TeamPresenter extends BasePresenter {
public async call({ userId, organizationId }: { userId: string; organizationId: string }) {
const result = await getTeamMembersAndInvites({
userId,
organizationId,
});
if (!result) {
return;
}
const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoleMap] =
await Promise.all([
getLimit(organizationId, "teamMembers", 100_000_000),
getCurrentPlan(organizationId),
getPlans(),
// RBAC role catalogue (system roles + any org-defined custom
// roles). The default fallback returns []; an installed plugin
// may return the seeded system roles plus any custom roles.
rbac.allRoles(organizationId),
// Plan-gated subset — the Teams page disables dropdown options not
// in this set. Server-side enforcement is independent (setUserRole
// rejects a plan-gated assignment regardless of UI state).
rbac.getAssignableRoleIds(organizationId),
// Per-member current role in a single round-trip.
rbac.getUserRoles(
result.members.map((m) => m.user.id),
organizationId
),
]);
const memberRoles = result.members.map((m) => ({
userId: m.user.id,
role: memberRoleMap.get(m.user.id) ?? null,
}));
const canPurchaseSeats =
currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true;
const extraSeats = currentPlan?.v3Subscription?.addOns?.seats?.purchased ?? 0;
const maxSeatQuota = currentPlan?.v3Subscription?.addOns?.seats?.quota ?? 0;
const planSeatLimit = currentPlan?.v3Subscription?.plan?.limits.teamMembers.number ?? 0;
const seatPricing = plans?.addOnPricing.seats ?? null;
const limit = baseLimit + extraSeats;
return {
...result,
limits: {
used: result.members.length + result.invites.length,
limit,
},
canPurchaseSeats,
extraSeats,
seatPricing,
maxSeatQuota,
planSeatLimit,
roles,
assignableRoleIds,
memberRoles,
};
}
}
@@ -0,0 +1,359 @@
import { type ClickHouse } from "@internal/clickhouse";
import { type PrismaClientOrTransaction, type RuntimeEnvironmentType } from "@trigger.dev/database";
import { z } from "zod";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import {
chooseBucketSeconds,
groupRunStatus,
RUN_STATUS_GROUPS,
zeroFillGroupedSeries,
zeroFillScalarSeries,
} from "./activitySeries.server";
export type AgentDetail = {
slug: string;
filePath: string;
triggerSource: "AGENT";
createdAt: Date;
config: unknown;
};
export type AgentActivityPoint = {
bucket: number; // epoch ms
} & Record<string, number>;
export type AgentActivity = {
data: AgentActivityPoint[];
statuses: string[];
};
// Stable legend order for the sessions activity chart. Derived statuses:
// ACTIVE = closed_at IS NULL AND (expires_at IS NULL OR expires_at > now)
// CLOSED = closed_at IS NOT NULL
// EXPIRED = closed_at IS NULL AND expires_at <= now
const SESSION_STATUSES = ["ACTIVE", "CLOSED", "EXPIRED"] as const;
type SessionStatusLabel = (typeof SESSION_STATUSES)[number];
export class AgentDetailPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {}
async findAgent({
environmentId,
environmentType,
agentSlug,
}: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
agentSlug: string;
}): Promise<AgentDetail | null> {
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: environmentId, type: environmentType },
this.replica
);
if (!currentWorker) return null;
const task = await this.replica.backgroundWorkerTask.findFirst({
where: {
workerId: currentWorker.id,
slug: agentSlug,
triggerSource: "AGENT",
},
select: {
slug: true,
filePath: true,
triggerSource: true,
config: true,
createdAt: true,
},
});
if (!task) return null;
return {
slug: task.slug,
filePath: task.filePath,
triggerSource: "AGENT",
createdAt: task.createdAt,
config: task.config,
};
}
async getActivity({
organizationId,
projectId,
environmentId,
agentSlug,
from,
to,
}: {
organizationId: string;
projectId: string;
environmentId: string;
agentSlug: string;
from: Date;
to: Date;
}): Promise<AgentActivity> {
const rangeMs = Math.max(1, to.getTime() - from.getTime());
const bucketSeconds = chooseBucketSeconds(rangeMs);
// NOTE: We intentionally don't filter by `task_kind = 'AGENT'` here:
// ClickHouse stores `task_kind = ""` for pre-migration rows and rows
// whose taskKind annotation was never set, even for AGENT tasks. We've
// already verified this task is an agent via `findAgent` (Postgres), so
// matching on environment_id + task_identifier is sufficient.
//
// FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree;
// org/project filters engage the sort-key prefix for partition pruning.
const queryFn = this.clickhouse.reader.query({
name: "agentRunStatusActivity",
query: `SELECT
toUnixTimestamp(toStartOfInterval(created_at, INTERVAL {bucketSeconds: UInt32} SECOND)) AS bucket,
status,
count() AS val
FROM trigger_dev.task_runs_v2 FINAL
WHERE organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND task_identifier = {agentSlug: String}
AND created_at >= {fromTime: DateTime64(3, 'UTC')}
AND created_at < {toTime: DateTime64(3, 'UTC')}
AND _is_deleted = 0
GROUP BY bucket, status
ORDER BY bucket`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
agentSlug: z.string(),
bucketSeconds: z.number(),
fromTime: z.string(),
toTime: z.string(),
}),
schema: z.object({
bucket: z.coerce.number(),
status: z.string(),
val: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({
organizationId,
projectId,
environmentId,
agentSlug,
bucketSeconds,
// ClickHouse's DateTime64(3, 'UTC') parser rejects the trailing `Z` from
// JS toISOString() ("only 23 of 24 bytes was parsed"). Strip it.
fromTime: from.toISOString().slice(0, -1),
toTime: to.toISOString().slice(0, -1),
});
if (error) {
console.error("Agent activity query failed:", error);
return { data: [], statuses: [] };
}
// Always emit every status group so the chart legend is stable across time
// ranges (even when a group has no runs in the current window).
const points = zeroFillGroupedSeries({
rows,
from,
to,
bucketSeconds,
orderedKeys: RUN_STATUS_GROUPS,
groupFn: groupRunStatus,
fallbackKey: "RUNNING",
});
return { data: points, statuses: [...RUN_STATUS_GROUPS] };
}
async getSessionActivity({
organizationId,
projectId,
environmentId,
agentSlug,
from,
to,
}: {
organizationId: string;
projectId: string;
environmentId: string;
agentSlug: string;
from: Date;
to: Date;
}): Promise<AgentActivity> {
const rangeMs = Math.max(1, to.getTime() - from.getTime());
const bucketSeconds = chooseBucketSeconds(rangeMs);
// FINAL collapses ReplacingMergeTree versions so we see each session's
// latest state — important since closed_at / expires_at are mutated
// post-insert. Org/project filters engage the sort-key prefix.
const queryFn = this.clickhouse.reader.query({
name: "agentSessionStatusActivity",
query: `SELECT
toUnixTimestamp(toStartOfInterval(created_at, INTERVAL {bucketSeconds: UInt32} SECOND)) AS bucket,
multiIf(
closed_at IS NOT NULL, 'CLOSED',
expires_at IS NOT NULL AND expires_at <= now64(3), 'EXPIRED',
'ACTIVE'
) AS status,
count() AS val
FROM trigger_dev.sessions_v1 FINAL
WHERE organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND task_identifier = {agentSlug: String}
AND _is_deleted = 0
AND created_at >= {fromTime: DateTime64(3, 'UTC')}
AND created_at < {toTime: DateTime64(3, 'UTC')}
GROUP BY bucket, status
ORDER BY bucket`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
agentSlug: z.string(),
bucketSeconds: z.number(),
fromTime: z.string(),
toTime: z.string(),
}),
schema: z.object({
bucket: z.coerce.number(),
status: z.string(),
val: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({
organizationId,
projectId,
environmentId,
agentSlug,
bucketSeconds,
fromTime: from.toISOString().slice(0, -1),
toTime: to.toISOString().slice(0, -1),
});
if (error) {
console.error("Agent session activity query failed:", error);
return { data: [], statuses: [] };
}
const orderedStatuses: SessionStatusLabel[] = [...SESSION_STATUSES];
const points = zeroFillGroupedSeries({
rows,
from,
to,
bucketSeconds,
orderedKeys: orderedStatuses,
});
return { data: points, statuses: orderedStatuses };
}
async getLlmCostActivity(input: {
organizationId: string;
projectId: string;
environmentId: string;
agentSlug: string;
from: Date;
to: Date;
}): Promise<AgentActivity> {
return this.#llmActivity({ ...input, metric: "cost" });
}
async getLlmTokenActivity(input: {
organizationId: string;
projectId: string;
environmentId: string;
agentSlug: string;
from: Date;
to: Date;
}): Promise<AgentActivity> {
return this.#llmActivity({ ...input, metric: "tokens" });
}
async #llmActivity({
organizationId,
projectId,
environmentId,
agentSlug,
from,
to,
metric,
}: {
organizationId: string;
projectId: string;
environmentId: string;
agentSlug: string;
from: Date;
to: Date;
metric: "cost" | "tokens";
}): Promise<AgentActivity> {
const rangeMs = Math.max(1, to.getTime() - from.getTime());
const bucketSeconds = chooseBucketSeconds(rangeMs);
const seriesKey = metric === "cost" ? "cost" : "tokens";
// total_cost is Decimal64(12); cast to Float64 so the JSON wire format is
// a plain number rather than a string.
const sumExpr = metric === "cost" ? "toFloat64(sum(total_cost))" : "sum(total_tokens)";
// llm_metrics_v1 is partitioned by toDate(inserted_at); filtering on
// inserted_at lets ClickHouse prune partitions (start_time alone touches
// every monthly partition in the 365d TTL). The writer sets
// inserted_at = now64(3) at insert, so inserted_at >= start_time is an
// invariant and using `fromTime` here is safe.
const queryFn = this.clickhouse.reader.query({
name: metric === "cost" ? "agentLlmCostActivity" : "agentLlmTokenActivity",
query: `SELECT
toUnixTimestamp(toStartOfInterval(start_time, INTERVAL {bucketSeconds: UInt32} SECOND)) AS bucket,
${sumExpr} AS val
FROM trigger_dev.llm_metrics_v1
WHERE organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND task_identifier = {agentSlug: String}
AND inserted_at >= {fromTime: DateTime64(3, 'UTC')}
AND start_time >= {fromTime: DateTime64(3, 'UTC')}
AND start_time < {toTime: DateTime64(3, 'UTC')}
GROUP BY bucket
ORDER BY bucket`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
agentSlug: z.string(),
bucketSeconds: z.number(),
fromTime: z.string(),
toTime: z.string(),
}),
schema: z.object({
bucket: z.coerce.number(),
val: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({
organizationId,
projectId,
environmentId,
agentSlug,
bucketSeconds,
fromTime: from.toISOString().slice(0, -1),
toTime: to.toISOString().slice(0, -1),
});
if (error) {
console.error(`Agent LLM ${metric} activity query failed:`, error);
return { data: [], statuses: [] };
}
const points = zeroFillScalarSeries({ rows, from, to, bucketSeconds, seriesKey });
return { data: points, statuses: [seriesKey] };
}
}
@@ -0,0 +1,305 @@
import {
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskTriggerSource,
} from "@trigger.dev/database";
import { type ClickHouse } from "@internal/clickhouse";
import { z } from "zod";
import { $replica } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
export type AgentListItem = {
slug: string;
filePath: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
config: unknown;
};
export type AgentActiveState = {
running: number;
suspended: number;
};
export class AgentListPresenter {
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call({
organizationId,
projectId,
environmentId,
environmentType,
currentWorker: preloadedCurrentWorker,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
/** Optional: pass the pre-resolved current worker to skip the lookup. Used
* by `UnifiedTaskListPresenter` to share one lookup across both presenters. */
currentWorker?: Awaited<ReturnType<typeof findCurrentWorkerFromEnvironment>>;
}) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const currentWorker =
preloadedCurrentWorker !== undefined
? preloadedCurrentWorker
: await findCurrentWorkerFromEnvironment(
{
id: environmentId,
type: environmentType,
},
this._replica
);
if (!currentWorker) {
return {
agents: [],
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
costSparklines: Promise.resolve({} as Record<string, number[]>),
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
};
}
const agents = await this._replica.backgroundWorkerTask.findMany({
where: {
workerId: currentWorker.id,
triggerSource: "AGENT",
},
select: {
id: true,
slug: true,
filePath: true,
triggerSource: true,
config: true,
createdAt: true,
},
orderBy: {
slug: "asc",
},
});
const slugs = agents.map((a) => a.slug);
if (slugs.length === 0) {
return {
agents,
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
costSparklines: Promise.resolve({} as Record<string, number[]>),
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
};
}
// All queries are deferred for streaming
const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs);
const conversationSparklines = this.#getConversationSparklines(
clickhouse,
environmentId,
slugs
);
const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs);
const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs);
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
}
/** Count runs currently executing vs suspended per agent */
async #getActiveStates(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, AgentActiveState>> {
const queryFn = clickhouse.reader.query({
name: "agentActiveStates",
query: `SELECT
task_identifier,
countIf(status = 'EXECUTING') AS running,
countIf(status IN ('WAITING_TO_RESUME', 'QUEUED_EXECUTING')) AS suspended
FROM trigger_dev.task_runs_v2
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND task_kind = 'AGENT'
AND status IN ('EXECUTING', 'WAITING_TO_RESUME', 'QUEUED_EXECUTING')
GROUP BY task_identifier`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
running: z.coerce.number(),
suspended: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({ environmentId, slugs });
if (error) {
console.error("Agent active states query failed:", error);
return {};
}
const result: Record<string, AgentActiveState> = {};
for (const row of rows) {
result[row.task_identifier] = { running: row.running, suspended: row.suspended };
}
return result;
}
/** 24h hourly sparkline of conversation (run) count per agent */
async #getConversationSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = clickhouse.reader.query({
name: "agentConversationSparklines",
query: `SELECT
task_identifier,
toStartOfHour(created_at) AS bucket,
count() AS val
FROM trigger_dev.task_runs_v2
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND task_kind = 'AGENT'
AND created_at >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** 24h hourly sparkline of LLM cost per agent */
async #getCostSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = clickhouse.reader.query({
name: "agentCostSparklines",
query: `SELECT
task_identifier,
toStartOfHour(start_time) AS bucket,
sum(total_cost) AS val
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** 24h hourly sparkline of total tokens per agent */
async #getTokenSparklines(
clickhouse: ClickHouse,
environmentId: string,
slugs: string[]
): Promise<Record<string, number[]>> {
const queryFn = clickhouse.reader.query({
name: "agentTokenSparklines",
query: `SELECT
task_identifier,
toStartOfHour(start_time) AS bucket,
sum(total_tokens) AS val
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY task_identifier, bucket
ORDER BY task_identifier, bucket`,
params: z.object({
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
val: z.coerce.number(),
}),
});
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
}
/** Convert ClickHouse query result to sparkline map with zero-filled 24 hourly buckets */
#buildSparklineMap(
queryResult: [Error, null] | [null, { task_identifier: string; bucket: string; val: number }[]],
slugs: string[]
): Record<string, number[]> {
const [error, rows] = queryResult;
if (error) {
console.error("Agent sparkline query failed:", error);
return {};
}
return this.#buildSparklineFromRows(rows, slugs);
}
#buildSparklineFromRows(
rows: { task_identifier: string; bucket: string; val: number }[],
slugs: string[]
): Record<string, number[]> {
const now = new Date();
const startHour = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours() - 23,
0,
0,
0
)
);
const bucketKeys: string[] = [];
for (let i = 0; i < 24; i++) {
const h = new Date(startHour.getTime() + i * 3600_000);
bucketKeys.push(h.toISOString().slice(0, 13).replace("T", " ") + ":00:00");
}
const rowMap = new Map<string, number>();
for (const row of rows) {
rowMap.set(`${row.task_identifier}|${row.bucket}`, row.val);
}
const result: Record<string, number[]> = {};
for (const slug of slugs) {
result[slug] = bucketKeys.map((key) => rowMap.get(`${slug}|${key}`) ?? 0);
}
return result;
}
}
export const agentListPresenter = singleton("agentListPresenter", setupAgentListPresenter);
function setupAgentListPresenter() {
return new AgentListPresenter($replica);
}
@@ -0,0 +1,98 @@
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { type RuntimeEnvironmentType, type ProjectAlertChannel } from "@trigger.dev/database";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { getLimit } from "~/services/platform.v3.server";
export type AlertChannelListPresenterData = Awaited<ReturnType<AlertChannelListPresenter["call"]>>;
export type AlertChannelListPresenterRecord =
AlertChannelListPresenterData["alertChannels"][number];
export type AlertChannelListPresenterAlertProperties = NonNullable<
AlertChannelListPresenterRecord["properties"]
>;
export class AlertChannelListPresenter extends BasePresenter {
public async call(projectId: string, environmentType?: RuntimeEnvironmentType) {
logger.debug("AlertChannelListPresenter", { projectId });
const alertChannels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId,
},
orderBy: {
createdAt: "desc",
},
});
const organization = await this._replica.project.findFirst({
where: {
id: projectId,
},
select: {
organizationId: true,
},
});
if (!organization) {
throw new Error(`Project not found: ${projectId}`);
}
const limit = await getLimit(organization.organizationId, "alerts", 100_000_000);
const relevantChannels = alertChannels.filter((channel) => {
if (!environmentType) return true;
return channel.environmentTypes.includes(environmentType);
});
return {
alertChannels: await Promise.all(
relevantChannels.map(async (alertChannel) => ({
...alertChannel,
properties: await this.#presentProperties(alertChannel),
}))
),
limits: {
used: alertChannels.length,
limit,
},
};
}
async #presentProperties(alertChannel: ProjectAlertChannel) {
if (!alertChannel.properties) {
return;
}
switch (alertChannel.type) {
case "WEBHOOK":
const parsedProperties = ProjectAlertWebhookProperties.parse(alertChannel.properties);
const secret = await decryptSecret(env.ENCRYPTION_KEY, parsedProperties.secret);
return {
type: "WEBHOOK" as const,
url: parsedProperties.url,
secret,
};
case "EMAIL":
return {
type: "EMAIL" as const,
...ProjectAlertEmailProperties.parse(alertChannel.properties),
};
case "SLACK": {
return {
type: "SLACK" as const,
...ProjectAlertSlackProperties.parse(alertChannel.properties),
};
}
default:
throw new Error(`Unsupported alert channel type: ${alertChannel.type}`);
}
}
}
@@ -0,0 +1,150 @@
import {
type ProjectAlertChannel,
type ProjectAlertChannelType,
type ProjectAlertType,
} from "@trigger.dev/database";
import assertNever from "assert-never";
import { z } from "zod";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
export const ApiAlertType = z.enum([
"run_failure",
"attempt_failure",
"deployment_failure",
"deployment_success",
"error_group",
]);
export type ApiAlertType = z.infer<typeof ApiAlertType>;
export const ApiAlertEnvironmentType = z.enum(["STAGING", "PRODUCTION"]);
export type ApiAlertEnvironmentType = z.infer<typeof ApiAlertEnvironmentType>;
export const ApiAlertChannel = z.enum(["email", "webhook"]);
export type ApiAlertChannel = z.infer<typeof ApiAlertChannel>;
export const ApiAlertChannelData = z.object({
email: z.string().optional(),
url: z.string().optional(),
secret: z.string().optional(),
});
export type ApiAlertChannelData = z.infer<typeof ApiAlertChannelData>;
export const ApiCreateAlertChannel = z.object({
alertTypes: ApiAlertType.array(),
name: z.string(),
channel: ApiAlertChannel,
channelData: ApiAlertChannelData,
deduplicationKey: z.string().optional(),
environmentTypes: ApiAlertEnvironmentType.array().default(["STAGING", "PRODUCTION"]),
});
export type ApiCreateAlertChannel = z.infer<typeof ApiCreateAlertChannel>;
export const ApiAlertChannelObject = z.object({
id: z.string(),
name: z.string(),
alertTypes: ApiAlertType.array(),
channel: ApiAlertChannel,
channelData: ApiAlertChannelData,
deduplicationKey: z.string().optional(),
});
export type ApiAlertChannelObject = z.infer<typeof ApiAlertChannelObject>;
export class ApiAlertChannelPresenter {
public static async alertChannelToApi(
alertChannel: ProjectAlertChannel
): Promise<ApiAlertChannelObject> {
return {
id: alertChannel.friendlyId,
name: alertChannel.name,
alertTypes: alertChannel.alertTypes.map((type) => this.alertTypeToApi(type)),
channel: this.alertChannelTypeToApi(alertChannel.type),
channelData: await channelDataFromProperties(alertChannel.type, alertChannel.properties),
deduplicationKey: alertChannel.userProvidedDeduplicationKey
? alertChannel.deduplicationKey
: undefined,
};
}
public static alertTypeToApi(alertType: ProjectAlertType): ApiAlertType {
switch (alertType) {
case "TASK_RUN":
return "run_failure";
case "TASK_RUN_ATTEMPT":
return "attempt_failure";
case "DEPLOYMENT_FAILURE":
return "deployment_failure";
case "DEPLOYMENT_SUCCESS":
return "deployment_success";
case "ERROR_GROUP":
return "error_group";
default:
assertNever(alertType);
}
}
public static alertTypeFromApi(alertType: ApiAlertType): ProjectAlertType {
switch (alertType) {
case "run_failure":
return "TASK_RUN";
case "attempt_failure":
return "TASK_RUN_ATTEMPT";
case "deployment_failure":
return "DEPLOYMENT_FAILURE";
case "deployment_success":
return "DEPLOYMENT_SUCCESS";
case "error_group":
return "ERROR_GROUP";
default:
assertNever(alertType);
}
}
public static alertChannelTypeToApi(type: ProjectAlertChannelType): ApiAlertChannel {
switch (type) {
case "EMAIL":
return "email";
case "WEBHOOK":
return "webhook";
case "SLACK":
throw new Error("Slack channels are not supported");
default:
assertNever(type);
}
}
}
async function channelDataFromProperties(
type: ProjectAlertChannelType,
properties: ProjectAlertChannel["properties"]
): Promise<ApiAlertChannelData> {
if (!properties) {
return {};
}
switch (type) {
case "EMAIL":
return ProjectAlertEmailProperties.parse(properties);
case "WEBHOOK":
const { url, secret } = ProjectAlertWebhookProperties.parse(properties);
return {
url,
secret: await decryptSecret(env.ENCRYPTION_KEY, secret),
};
case "SLACK":
throw new Error("Slack channels are not supported");
default:
assertNever(type);
}
}
@@ -0,0 +1,219 @@
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
import {
$replica,
type PrismaClientOrTransaction,
type PrismaReplicaClient,
prisma,
} from "~/db.server";
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
import { executionResultForTaskRun } from "~/models/taskRun.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
import { BasePresenter } from "./basePresenter.server";
/**
* Run-ops read-through wiring. All optional; absent (or `splitEnabled` falsy) collapses `call` to
* passthrough. `legacyReplica` is a READ REPLICA handle only — there is NO legacy-primary field.
*/
type ApiBatchResultsReadThroughDeps = {
splitEnabled?: boolean;
newClient?: PrismaReplicaClient;
legacyReplica?: PrismaReplicaClient;
isPastRetention?: (runId: string) => boolean;
};
// The TaskRun shape `executionResultForTaskRun` consumes. Shared by both read sites.
const memberRunSelect = {
id: true,
friendlyId: true,
status: true,
taskIdentifier: true,
attempts: {
select: {
status: true,
output: true,
outputType: true,
error: true,
},
orderBy: {
createdAt: "desc",
},
},
} as const;
/**
* Split on: the batch row + its item rows resolve new-run-ops first, then the LEGACY RUN-OPS
* READ REPLICA ONLY (never the legacy primary — there is no such handle); each member run is
* hydrated independently via readThroughRun keyed on the member runId, so a batch whose members
* span migrated + abandoned runs returns the complete reachable set (the batch-spanning-the-line
* read; the dangling-reference termination gate is a separate, adjacent unit).
*
* Split off (single-DB / self-host): one passthrough read for the batch row + a single store
* id-set hydrate for the members — no legacy read, no known-migrated probe, no second connection.
*/
export class ApiBatchResultsPresenter extends BasePresenter {
constructor(
prismaClient: PrismaClientOrTransaction = prisma,
replicaClient: PrismaClientOrTransaction = $replica,
private readonly readThrough?: ApiBatchResultsReadThroughDeps,
private readonly runStore = defaultRunStore
) {
super(prismaClient, replicaClient);
}
public async call(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<BatchTaskRunExecutionResult | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const splitEnabled = this.readThrough?.splitEnabled ?? false;
if (!splitEnabled) {
return this.#callPassthrough(friendlyId, env);
}
return this.#callSplit(friendlyId, env);
});
}
// Passthrough: batch row off the replica, members via the single run store. No legacy read.
async #callPassthrough(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<BatchTaskRunExecutionResult | undefined> {
const batchRun = await this._replica.batchTaskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
},
include: {
items: {
select: {
taskRunId: true,
},
},
},
});
if (!batchRun) {
return undefined;
}
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
if (taskRunIds.length === 0) {
return {
id: batchRun.friendlyId,
items: [],
};
}
const taskRuns = await this.runStore.findRuns(
{
where: { id: { in: taskRunIds } },
select: memberRunSelect,
},
this._prisma
);
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
return {
id: batchRun.friendlyId,
items: batchRun.items
.map((item) => {
const run = runMap.get(item.taskRunId);
return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined;
})
.filter(Boolean),
};
}
// Split: resolve the batch row new-first then off the legacy READ REPLICA only (a batch id may
// be cuid or run-ops id, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
// residency is not authoritative for the row — the new-first-then-legacy probe is), then
// hydrate every member run independently via the per-run read-through primitive.
async #callSplit(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<BatchTaskRunExecutionResult | undefined> {
// Resolve both handles ONCE so the batch row and its members never read from different DBs.
const newClient = (this.readThrough?.newClient ?? this._replica) as PrismaReplicaClient;
const legacyReplica = (this.readThrough?.legacyReplica ?? this._replica) as PrismaReplicaClient;
const readBatch = (client: PrismaClientOrTransaction) =>
client.batchTaskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
},
include: {
items: {
select: {
taskRunId: true,
},
},
},
});
let batchRun = await readBatch(newClient);
// Legacy READ REPLICA probe, only on a new-probe miss; skipped when past retention.
if (!batchRun && !this.readThrough?.isPastRetention?.(friendlyId)) {
batchRun = await readBatch(legacyReplica);
}
if (!batchRun) {
return undefined;
}
if (batchRun.items.length === 0) {
return {
id: batchRun.friendlyId,
items: [],
};
}
const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) =>
client.taskRun.findFirst({
where: { id: taskRunId },
select: memberRunSelect,
}) as Promise<TaskRunWithAttempts | null>;
// Per-member fan-out: each member may live on a different DB, so a single nested include cannot
// cross the seam. Promise.all preserves batchRun.items order, unchanged from today.
const memberResults = await Promise.all(
batchRun.items.map(async (item) => {
const result = await readThroughRun<TaskRunWithAttempts>({
runId: item.taskRunId,
environmentId: env.id,
readNew: (client) => readMemberRun(client, item.taskRunId),
readLegacy: (replica) => readMemberRun(replica, item.taskRunId),
deps: {
splitEnabled: true,
// Pass the SAME resolved handles the batch row used, so the batch row and its members
// never resolve against different DBs. (Letting these fall through to readThroughRun's
// own module-level defaults would diverge from the batch read's `?? this._replica`.)
newClient,
legacyReplica,
isPastRetention: this.readThrough?.isPastRetention,
},
});
// not-found / past-retention members are omitted (matches today's drop-undefined behavior);
// the dangling-reference termination gate (separate unit) governs whether that's permitted.
if (result.source === "not-found" || result.source === "past-retention") {
return undefined;
}
return executionResultForTaskRun(result.value);
})
);
return {
id: batchRun.friendlyId,
items: memberResults.filter(Boolean),
};
}
}
@@ -0,0 +1,155 @@
import {
type BulkActionGroup,
type BulkActionStatus,
type BulkActionType,
} from "@trigger.dev/database";
import { z } from "zod";
import { BasePresenter } from "./basePresenter.server";
const DEFAULT_PAGE_SIZE = 25;
const MAX_PAGE_SIZE = 100;
export const ApiBulkActionListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(MAX_PAGE_SIZE).optional(),
"page[after]": z.string().optional(),
"page[before]": z.string().optional(),
});
export type ApiBulkActionListSearchParams = z.infer<typeof ApiBulkActionListSearchParams>;
type BulkActionListCursor = {
createdAt: Date;
id: string;
};
type BulkActionRow = Pick<
BulkActionGroup,
| "id"
| "friendlyId"
| "name"
| "status"
| "type"
| "createdAt"
| "completedAt"
| "totalCount"
| "successCount"
| "failureCount"
>;
export class ApiBulkActionPresenter extends BasePresenter {
public async list(environmentId: string, searchParams: ApiBulkActionListSearchParams) {
const pageSize = searchParams["page[size]"] ?? DEFAULT_PAGE_SIZE;
const after = searchParams["page[after]"];
const before = searchParams["page[before]"];
if (after && before) {
throw new Error("Only one of page[after] or page[before] can be provided");
}
const cursor = decodeCursor(after ?? before);
const direction = before ? "backward" : "forward";
const where = {
environmentId,
...(cursor
? direction === "forward"
? {
OR: [
{ createdAt: { lt: cursor.createdAt } },
{ createdAt: cursor.createdAt, id: { lt: cursor.id } },
],
}
: {
OR: [
{ createdAt: { gt: cursor.createdAt } },
{ createdAt: cursor.createdAt, id: { gt: cursor.id } },
],
}
: {}),
};
const rows = await this._replica.bulkActionGroup.findMany({
select: bulkActionSelect,
where,
orderBy:
direction === "forward"
? [{ createdAt: "desc" }, { id: "desc" }]
: [{ createdAt: "asc" }, { id: "asc" }],
take: pageSize + 1,
});
const hasMore = rows.length > pageSize;
const pageRows = rows.slice(0, pageSize);
const dataRows = direction === "forward" ? pageRows : [...pageRows].reverse();
const first = dataRows.at(0);
const last = dataRows.at(-1);
return {
data: dataRows.map(apiBulkActionObject),
pagination: {
next: last && (hasMore || direction === "backward") ? encodeCursor(last) : undefined,
previous:
first &&
((direction === "forward" && Boolean(after)) || (direction === "backward" && hasMore))
? encodeCursor(first)
: undefined,
},
};
}
}
export const bulkActionSelect = {
id: true,
friendlyId: true,
name: true,
status: true,
type: true,
createdAt: true,
completedAt: true,
totalCount: true,
successCount: true,
failureCount: true,
} as const;
export function apiBulkActionObject(row: BulkActionRow) {
return {
id: row.friendlyId,
name: row.name ?? undefined,
type: row.type as BulkActionType,
status: row.status as BulkActionStatus,
counts: {
total: row.totalCount,
success: row.successCount,
failure: row.failureCount,
},
createdAt: row.createdAt,
completedAt: row.completedAt ?? undefined,
};
}
function encodeCursor(row: Pick<BulkActionRow, "createdAt" | "id">) {
return Buffer.from(JSON.stringify({ createdAt: row.createdAt.getTime(), id: row.id })).toString(
"base64url"
);
}
function decodeCursor(cursor: string | undefined): BulkActionListCursor | undefined {
if (!cursor) {
return undefined;
}
try {
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as {
createdAt?: unknown;
id?: unknown;
};
if (typeof parsed.createdAt !== "number" || typeof parsed.id !== "string") {
throw new Error("Invalid cursor");
}
return { createdAt: new Date(parsed.createdAt), id: parsed.id };
} catch {
throw new Error("Invalid cursor");
}
}
@@ -0,0 +1,227 @@
import { type ErrorGroupDetail } from "@trigger.dev/core/v3";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { type ErrorGroupStatus } from "@trigger.dev/database";
import { type ApiAuthenticationResultSuccess } from "~/services/apiAuth.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { sortVersionsDescending } from "~/utils/semver";
import { BasePresenter } from "./basePresenter.server";
/**
* Resolves the friendly `error_<fingerprint>` id from the URL to the full error
* group detail for the authenticated environment, or `undefined` if it doesn't
* exist. Shared by the detail loader and the resolve/ignore/unresolve actions.
*/
export function findErrorGroupResource(
authentication: ApiAuthenticationResultSuccess,
errorId: string
): Promise<ErrorGroupDetail | undefined> {
const fingerprint = ErrorId.toId(errorId);
return new ApiErrorGroupPresenter().call(
authentication.environment.organizationId,
authentication.environment.project.id,
authentication.environment.id,
fingerprint
);
}
const DB_STATUS_TO_API: Record<ErrorGroupStatus, ErrorGroupDetail["status"]> = {
UNRESOLVED: "unresolved",
RESOLVED: "resolved",
IGNORED: "ignored",
};
function parseClickHouseDateTime(value: string): Date {
const asNum = Number(value);
if (!isNaN(asNum) && asNum > 1e12) {
return new Date(asNum);
}
return new Date(value.replace(" ", "T") + "Z");
}
export class ApiErrorGroupPresenter extends BasePresenter {
/**
* Resolves a single error group to its API detail shape, or `undefined` if no
* such fingerprint exists in the environment (the route turns that into 404).
* Reuses the same ClickHouse query builders + `ErrorGroupState` reads the
* dashboard presenter uses.
*/
public async call(
organizationId: string,
projectId: string,
environmentId: string,
fingerprint: string
): Promise<ErrorGroupDetail | undefined> {
return this.trace("call", async () => {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"logs"
);
const summary = await this.getSummary(
clickhouse,
organizationId,
projectId,
environmentId,
fingerprint
);
if (!summary) {
return undefined;
}
const [affectedVersions, state] = await Promise.all([
this.getAffectedVersions(clickhouse, organizationId, projectId, environmentId, fingerprint),
this.getState(environmentId, summary.taskIdentifier, fingerprint),
]);
return {
id: ErrorId.toFriendlyId(fingerprint),
fingerprint,
taskIdentifier: summary.taskIdentifier,
errorType: summary.errorType,
errorMessage: summary.errorMessage,
count: summary.count,
firstSeen: summary.firstSeen,
lastSeen: summary.lastSeen,
affectedVersions,
status: state ? DB_STATUS_TO_API[state.status] : "unresolved",
resolvedAt: state?.resolvedAt ?? null,
resolvedInVersion: state?.resolvedInVersion ?? null,
resolvedBy: state?.resolvedBy ?? null,
ignoredAt: state?.ignoredAt ?? null,
ignoredUntil: state?.ignoredUntil ?? null,
ignoredReason: state?.ignoredReason ?? null,
ignoredByUserId: state?.ignoredByUserId ?? null,
ignoredUntilOccurrenceRate: state?.ignoredUntilOccurrenceRate ?? null,
ignoredUntilTotalOccurrences: state?.ignoredUntilTotalOccurrences ?? null,
};
});
}
private async getSummary(
clickhouse: Awaited<ReturnType<typeof clickhouseFactory.getClickhouseForOrganization>>,
organizationId: string,
projectId: string,
environmentId: string,
fingerprint: string
): Promise<
| {
taskIdentifier: string;
errorType: string;
errorMessage: string;
count: number;
firstSeen: Date;
lastSeen: Date;
}
| undefined
> {
const queryBuilder = clickhouse.errors.listQueryBuilder();
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint });
queryBuilder.groupBy("error_fingerprint, task_identifier");
queryBuilder.limit(1);
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (!records || records.length === 0) {
return undefined;
}
const record = records[0];
return {
taskIdentifier: record.task_identifier,
errorType: record.error_type,
errorMessage: record.error_message,
count: record.occurrence_count,
firstSeen: parseClickHouseDateTime(record.first_seen),
lastSeen: parseClickHouseDateTime(record.last_seen),
};
}
private async getAffectedVersions(
clickhouse: Awaited<ReturnType<typeof clickhouseFactory.getClickhouseForOrganization>>,
organizationId: string,
projectId: string,
environmentId: string,
fingerprint: string
): Promise<string[]> {
const queryBuilder = clickhouse.errors.affectedVersionsQueryBuilder();
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint });
queryBuilder.where("task_version != ''");
queryBuilder.limit(100);
const [queryError, records] = await queryBuilder.execute();
if (queryError || !records) {
return [];
}
const versions = records.map((r) => r.task_version).filter((v) => v.length > 0);
return sortVersionsDescending(versions).slice(0, 5);
}
private async getState(
environmentId: string,
taskIdentifier: string,
fingerprint: string
): Promise<{
status: ErrorGroupStatus;
resolvedAt: Date | null;
resolvedInVersion: string | null;
resolvedBy: string | null;
ignoredAt: Date | null;
ignoredUntil: Date | null;
ignoredReason: string | null;
ignoredByUserId: string | null;
ignoredUntilOccurrenceRate: number | null;
ignoredUntilTotalOccurrences: number | null;
} | null> {
const row = await this._replica.errorGroupState.findFirst({
where: {
environmentId,
taskIdentifier,
errorFingerprint: fingerprint,
},
select: {
status: true,
resolvedAt: true,
resolvedInVersion: true,
resolvedBy: true,
ignoredAt: true,
ignoredUntil: true,
ignoredReason: true,
ignoredByUserId: true,
ignoredUntilOccurrenceRate: true,
ignoredUntilTotalOccurrences: true,
},
});
if (!row) {
return null;
}
return {
status: row.status,
resolvedAt: row.resolvedAt,
resolvedInVersion: row.resolvedInVersion,
resolvedBy: row.resolvedBy,
ignoredAt: row.ignoredAt,
ignoredUntil: row.ignoredUntil,
ignoredReason: row.ignoredReason,
ignoredByUserId: row.ignoredByUserId,
ignoredUntilOccurrenceRate: row.ignoredUntilOccurrenceRate,
ignoredUntilTotalOccurrences: row.ignoredUntilTotalOccurrences,
};
}
}
@@ -0,0 +1,165 @@
import { type ErrorGroupListItem } from "@trigger.dev/core/v3";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import {
type ErrorGroupStatus,
type Project,
type RuntimeEnvironment,
} from "@trigger.dev/database";
import { z } from "zod";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { CoercedDate } from "~/utils/zod";
import { ErrorsListPresenter, type ErrorsListOptions } from "./ErrorsListPresenter.server";
import { BasePresenter } from "./basePresenter.server";
// API status (lowercase) <-> DB ErrorGroupState.status (uppercase).
const API_STATUS_TO_DB: Record<string, ErrorGroupStatus> = {
unresolved: "UNRESOLVED",
resolved: "RESOLVED",
ignored: "IGNORED",
};
const DB_STATUS_TO_API: Record<ErrorGroupStatus, ErrorGroupListItem["status"]> = {
UNRESOLVED: "unresolved",
RESOLVED: "resolved",
IGNORED: "ignored",
};
export const ApiErrorListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
"page[after]": z.string().optional(),
"page[before]": z.string().optional(),
"filter[taskIdentifier]": z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
"filter[version]": z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
"filter[status]": z
.string()
.optional()
.transform((value, ctx) => {
if (!value) {
return undefined;
}
const statuses = value.split(",");
// hasOwnProperty, not `in`: `in` walks the prototype chain, so
// `filter[status]=toString` would pass and map to a function.
const invalid = statuses.filter(
(status) => !Object.prototype.hasOwnProperty.call(API_STATUS_TO_DB, status)
);
if (invalid.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid status values: ${invalid.join(
", "
)}. Allowed: unresolved, resolved, ignored.`,
});
return z.NEVER;
}
return Array.from(new Set(statuses.map((status) => API_STATUS_TO_DB[status])));
}),
"filter[search]": z.string().max(1000).optional(),
"filter[period]": z.string().optional(),
"filter[from]": CoercedDate,
"filter[to]": CoercedDate,
});
type ApiErrorListSearchParams = z.infer<typeof ApiErrorListSearchParams>;
export class ApiErrorListPresenter extends BasePresenter {
public async call(
project: Pick<Project, "id" | "organizationId">,
environment: Pick<RuntimeEnvironment, "id" | "organizationId">,
searchParams: ApiErrorListSearchParams
): Promise<{
data: ErrorGroupListItem[];
pagination: { next?: string; previous?: string };
}> {
return this.trace("call", async () => {
const options: ErrorsListOptions = {
projectId: project.id,
defaultPeriod: "1d",
};
if (searchParams["page[size]"]) {
options.pageSize = searchParams["page[size]"];
}
if (searchParams["page[after]"]) {
options.cursor = searchParams["page[after]"];
options.direction = "forward";
}
if (searchParams["page[before]"]) {
options.cursor = searchParams["page[before]"];
options.direction = "backward";
}
if (searchParams["filter[taskIdentifier]"]) {
options.tasks = searchParams["filter[taskIdentifier]"];
}
if (searchParams["filter[version]"]) {
options.versions = searchParams["filter[version]"];
}
if (searchParams["filter[status]"]) {
options.statuses = searchParams["filter[status]"];
}
if (searchParams["filter[search]"]) {
options.search = searchParams["filter[search]"];
}
if (searchParams["filter[period]"]) {
options.period = searchParams["filter[period]"];
}
if (searchParams["filter[from]"]) {
options.from = searchParams["filter[from]"].getTime();
}
if (searchParams["filter[to]"]) {
options.to = searchParams["filter[to]"].getTime();
}
const organizationId = environment.organizationId;
const plan = await getCurrentPlan(organizationId);
options.retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
// The errors data lives in the "logs" ClickHouse client, matching the
// dashboard list loader.
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"logs"
);
const presenter = new ErrorsListPresenter(this._replica, clickhouse);
const result = await presenter.call(organizationId, environment.id, options);
return {
data: result.errorGroups.map((group) => ({
id: ErrorId.toFriendlyId(group.fingerprint),
fingerprint: group.fingerprint,
taskIdentifier: group.taskIdentifier,
errorType: group.errorType,
errorMessage: group.errorMessage,
status: DB_STATUS_TO_API[group.status],
count: group.count,
firstSeen: group.firstSeen,
lastSeen: group.lastSeen,
resolvedAt: group.resolvedAt,
ignoredUntil: group.ignoredUntil,
})),
pagination: result.pagination,
};
});
}
}
@@ -0,0 +1,89 @@
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { type PrismaClient, prisma } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
export class ApiKeysPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
environmentSlug,
}: {
userId: User["id"];
projectSlug: Project["slug"];
environmentSlug: RuntimeEnvironment["slug"];
}) {
const environment = await this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
apiKey: true,
type: true,
slug: true,
updatedAt: true,
orgMember: {
select: {
userId: true,
},
},
branchName: true,
parentEnvironment: {
select: {
id: true,
apiKey: true,
},
},
project: {
select: {
id: true,
},
},
},
where: {
project: {
slug: projectSlug,
},
organization: {
members: {
some: {
userId,
},
},
},
slug: environmentSlug,
orgMember:
environmentSlug === "dev"
? {
userId,
}
: undefined,
},
});
if (!environment) {
throw new Error("Environment not found");
}
const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: environment.project.id,
deletedAt: null,
organizationIntegration: { service: "VERCEL", deletedAt: null },
},
select: { id: true },
});
return {
environment: {
...environment,
apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey,
},
hasVercelIntegration: vercelIntegration !== null,
};
}
}
@@ -0,0 +1,753 @@
import type {
AttemptStatus,
RunStatus,
SerializedError,
TriggerFunction,
} from "@trigger.dev/core/v3";
import {
TaskRunError,
conditionallyImportPacket,
createJsonErrorObject,
logger,
} from "@trigger.dev/core/v3";
import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly";
import type { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import type { API_VERSIONS, RunStatusUnspecifiedApiVersion } from "~/api/versions";
import { CURRENT_API_VERSION } from "~/api/versions";
import { $replica, prisma } from "~/db.server";
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import {
findRunByIdWithMollifierFallback,
type SyntheticRun,
} from "~/v3/mollifier/readFallback.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { tracer } from "~/v3/tracer.server";
import { startSpanWithEnv } from "~/v3/tracing.server";
const commonRunSelect = {
id: true,
friendlyId: true,
status: true,
taskIdentifier: true,
createdAt: true,
startedAt: true,
updatedAt: true,
completedAt: true,
expiredAt: true,
delayUntil: true,
metadata: true,
metadataType: true,
ttl: true,
costInCents: true,
baseCostInCents: true,
usageDurationMs: true,
idempotencyKey: true,
idempotencyKeyOptions: true,
isTest: true,
depth: true,
scheduleId: true,
workerQueue: true,
region: true,
lockedToVersionId: true,
resumeParentOnCompletion: true,
batch: {
select: {
id: true,
friendlyId: true,
},
},
runTags: true,
} satisfies Prisma.TaskRunSelect;
type CommonRelatedRun = Prisma.Result<
typeof prisma.taskRun,
{ select: typeof commonRunSelect },
"findFirstOrThrow"
>;
// commonRunSelect carries only the scalar `lockedToVersionId`; `version` is resolved via the
// control-plane resolver and folded back on for the run and each related run, since
// `createCommonRunStructure` reads `.lockedToVersion?.version` for all of them.
type CommonRelatedRunWithVersion = CommonRelatedRun & {
lockedToVersion: { version: string } | null;
};
// Full shape returned by findRun() — the commonRunSelect fields plus the
// extras the route handler reads. Declared explicitly (not inferred via
// ReturnType<typeof findRun>) so findRun can return a synthesised buffered
// run without the type becoming self-referential. Exported so the
// buffer-synthesis helper below can match this shape under unit test.
export type FoundRun = CommonRelatedRunWithVersion & {
traceId: string;
payload: string;
payloadType: string;
output: string | null;
outputType: string;
error: Prisma.JsonValue;
attempts: { id: string }[];
attemptNumber: number | null;
engine: "V1" | "V2";
taskEventStore: string;
parentTaskRun: CommonRelatedRunWithVersion | null;
rootTaskRun: CommonRelatedRunWithVersion | null;
childRuns: CommonRelatedRunWithVersion[];
// True when this run was synthesised from the mollifier buffer rather
// than read from Postgres. Callers that would otherwise query backing
// stores keyed on PG identifiers (e.g. ClickHouse event lookups by
// traceId) can short-circuit to an empty response — buffered runs
// haven't executed and have no events to fetch.
isBuffered: boolean;
};
export class ApiRetrieveRunPresenter {
constructor(private readonly apiVersion: API_VERSIONS) {}
public static async findRun(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<FoundRun | null> {
const pgRow = await runStore.findRun(
{
friendlyId,
runtimeEnvironmentId: env.id,
},
{
select: {
...commonRunSelect,
traceId: true,
payload: true,
payloadType: true,
output: true,
outputType: true,
error: true,
attempts: {
select: {
id: true,
},
},
attemptNumber: true,
engine: true,
taskEventStore: true,
parentTaskRun: {
select: commonRunSelect,
},
rootTaskRun: {
select: commonRunSelect,
},
childRuns: {
select: commonRunSelect,
},
},
},
$replica
);
if (pgRow) {
// Dedup distinct locked-version ids so each hits the resolver exactly once
// (unbounded childRuns mostly share the same lockedToVersionId).
const distinctVersionIds = new Set<string>();
const collect = (id: string | null) => {
if (id) {
distinctVersionIds.add(id);
}
};
collect(pgRow.lockedToVersionId);
collect(pgRow.parentTaskRun?.lockedToVersionId ?? null);
collect(pgRow.rootTaskRun?.lockedToVersionId ?? null);
for (const child of pgRow.childRuns) {
collect(child.lockedToVersionId);
}
const versionById = new Map<string, string | null>(
await Promise.all(
[...distinctVersionIds].map(
async (id) =>
[
id,
(await controlPlaneResolver.resolveRunLockedWorker({ lockedToVersionId: id }))
?.lockedToVersion?.version ?? null,
] as const
)
)
);
const resolveVersion = (id: string | null): { version: string } | null => {
if (!id) {
return null;
}
const version = versionById.get(id) ?? null;
return version !== null ? { version } : null;
};
const foldVersion = (run: CommonRelatedRun): CommonRelatedRunWithVersion => ({
...run,
lockedToVersion: resolveVersion(run.lockedToVersionId),
});
return {
...pgRow,
lockedToVersion: resolveVersion(pgRow.lockedToVersionId),
parentTaskRun: pgRow.parentTaskRun ? foldVersion(pgRow.parentTaskRun) : null,
rootTaskRun: pgRow.rootTaskRun ? foldVersion(pgRow.rootTaskRun) : null,
childRuns: pgRow.childRuns.map((child) => foldVersion(child)),
isBuffered: false,
};
}
// Postgres miss → fall back to the mollifier buffer. When the gate
// diverted a trigger, the run lives in Redis until the drainer replays
// it through engine.trigger. Synthesise the FoundRun shape so call()
// returns a `QUEUED` (or `FAILED`) response with empty output, no
// attempts, no relations.
const buffered = await findRunByIdWithMollifierFallback({
runId: friendlyId,
environmentId: env.id,
organizationId: env.organizationId,
});
if (!buffered) return null;
return synthesiseFoundRunFromBuffer(buffered);
}
public async call(taskRun: FoundRun, env: AuthenticatedEnvironment) {
return startSpanWithEnv(tracer, "ApiRetrieveRunPresenter.call", env, async () => {
let $payload: any;
let $payloadPresignedUrl: string | undefined;
let $output: any;
let $outputPresignedUrl: string | undefined;
const payloadPacket = await conditionallyImportPacket({
data: taskRun.payload,
dataType: taskRun.payloadType,
});
if (
payloadPacket.dataType === "application/store" &&
typeof payloadPacket.data === "string"
) {
const signed = await generatePresignedUrl(
env.project.externalRef,
env.slug,
payloadPacket.data,
"GET"
);
if (signed.success) {
$payloadPresignedUrl = signed.url;
} else {
logger.error(`Failed to generate presigned URL for payload: ${signed.error}`, {
taskRunId: taskRun.id,
payload: payloadPacket.data,
});
}
} else {
$payload = await parsePacketAsJson(payloadPacket);
}
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
const outputPacket = await conditionallyImportPacket({
data: taskRun.output ?? undefined,
dataType: taskRun.outputType,
});
if (
outputPacket.dataType === "application/store" &&
typeof outputPacket.data === "string"
) {
const signed = await generatePresignedUrl(
env.project.externalRef,
env.slug,
outputPacket.data,
"GET"
);
if (signed.success) {
$outputPresignedUrl = signed.url;
} else {
logger.error(`Failed to generate presigned URL for output: ${signed.error}`, {
taskRunId: taskRun.id,
output: outputPacket.data,
});
}
} else {
$output = await parsePacketAsJson(outputPacket);
}
}
return {
...(await createCommonRunStructure(taskRun, this.apiVersion)),
payload: $payload,
payloadPresignedUrl: $payloadPresignedUrl,
output: $output,
outputPresignedUrl: $outputPresignedUrl,
error: ApiRetrieveRunPresenter.apiErrorFromError(taskRun.error),
schedule: await resolveSchedule(taskRun),
// We're removing attempts from the API
attemptCount:
taskRun.engine === "V1" ? taskRun.attempts.length : (taskRun.attemptNumber ?? 0),
attempts: [],
relatedRuns: {
root: taskRun.rootTaskRun
? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion)
: undefined,
parent: taskRun.parentTaskRun
? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion)
: undefined,
children: await Promise.all(
taskRun.childRuns.map(async (r) => await createCommonRunStructure(r, this.apiVersion))
),
},
};
});
}
static apiErrorFromError(error: Prisma.JsonValue): SerializedError | undefined {
if (!error) {
return;
}
const errorData = TaskRunError.safeParse(error);
if (errorData.success) {
return createJsonErrorObject(errorData.data);
}
}
static isStatusFinished(status: RunStatus | RunStatusUnspecifiedApiVersion) {
return (
status === "COMPLETED" ||
status === "FAILED" ||
status === "CANCELED" ||
status === "INTERRUPTED" ||
status === "CRASHED" ||
status === "SYSTEM_FAILURE"
);
}
static apiStatusFromRunStatus(
status: TaskRunStatus,
apiVersion: API_VERSIONS
): RunStatus | RunStatusUnspecifiedApiVersion {
switch (apiVersion) {
case CURRENT_API_VERSION: {
return this.apiStatusFromRunStatusV2(status);
}
default: {
return this.apiStatusFromRunStatusV1(status);
}
}
}
static apiStatusFromRunStatusV1(status: TaskRunStatus): RunStatusUnspecifiedApiVersion {
switch (status) {
case "DELAYED": {
return "DELAYED";
}
case "PENDING_VERSION": {
return "PENDING_VERSION";
}
case "WAITING_FOR_DEPLOY": {
return "WAITING_FOR_DEPLOY";
}
case "PENDING": {
return "QUEUED";
}
case "PAUSED":
case "WAITING_TO_RESUME": {
return "FROZEN";
}
case "RETRYING_AFTER_FAILURE": {
return "REATTEMPTING";
}
case "DEQUEUED":
case "EXECUTING": {
return "EXECUTING";
}
case "CANCELED": {
return "CANCELED";
}
case "COMPLETED_SUCCESSFULLY": {
return "COMPLETED";
}
case "SYSTEM_FAILURE": {
return "SYSTEM_FAILURE";
}
case "INTERRUPTED": {
return "INTERRUPTED";
}
case "CRASHED": {
return "CRASHED";
}
case "COMPLETED_WITH_ERRORS": {
return "FAILED";
}
case "EXPIRED": {
return "EXPIRED";
}
case "TIMED_OUT": {
return "TIMED_OUT";
}
default: {
assertNever(status);
}
}
}
static apiStatusFromRunStatusV2(status: TaskRunStatus): RunStatus {
switch (status) {
case "DELAYED": {
return "DELAYED";
}
case "PENDING_VERSION": {
return "PENDING_VERSION";
}
case "WAITING_FOR_DEPLOY": {
return "PENDING_VERSION";
}
case "PENDING": {
return "QUEUED";
}
case "PAUSED":
case "WAITING_TO_RESUME": {
return "WAITING";
}
case "DEQUEUED": {
return "DEQUEUED";
}
case "RETRYING_AFTER_FAILURE":
case "EXECUTING": {
return "EXECUTING";
}
case "CANCELED": {
return "CANCELED";
}
case "COMPLETED_SUCCESSFULLY": {
return "COMPLETED";
}
case "SYSTEM_FAILURE": {
return "SYSTEM_FAILURE";
}
case "CRASHED": {
return "CRASHED";
}
case "INTERRUPTED":
case "COMPLETED_WITH_ERRORS": {
return "FAILED";
}
case "EXPIRED": {
return "EXPIRED";
}
case "TIMED_OUT": {
return "TIMED_OUT";
}
default: {
assertNever(status);
}
}
}
static apiBooleanHelpersFromTaskRunStatus(status: TaskRunStatus, apiVersion: API_VERSIONS) {
return ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
ApiRetrieveRunPresenter.apiStatusFromRunStatus(status, apiVersion)
);
}
static apiBooleanHelpersFromRunStatus(status: RunStatus | RunStatusUnspecifiedApiVersion) {
const isQueued =
status === "QUEUED" ||
status === "WAITING_FOR_DEPLOY" ||
status === "DELAYED" ||
status === "PENDING_VERSION";
const isExecuting =
status === "EXECUTING" ||
status === "REATTEMPTING" ||
status === "FROZEN" ||
status === "DEQUEUED";
const isCompleted =
status === "COMPLETED" ||
status === "CANCELED" ||
status === "FAILED" ||
status === "CRASHED" ||
status === "INTERRUPTED" ||
status === "SYSTEM_FAILURE";
const isFailed = isCompleted && status !== "COMPLETED";
const isSuccess = isCompleted && status === "COMPLETED";
const isCancelled = status === "CANCELED";
const isWaiting = status === "WAITING";
return {
isQueued,
isExecuting,
isCompleted,
isFailed,
isSuccess,
isCancelled,
isWaiting,
};
}
static apiStatusFromAttemptStatus(status: TaskRunAttemptStatus): AttemptStatus {
switch (status) {
case "PENDING": {
return "PENDING";
}
case "PAUSED": {
return "PAUSED";
}
case "EXECUTING": {
return "EXECUTING";
}
case "COMPLETED": {
return "COMPLETED";
}
case "FAILED": {
return "FAILED";
}
case "CANCELED": {
return "CANCELED";
}
default: {
assertNever(status);
}
}
}
}
async function resolveSchedule(run: CommonRelatedRun) {
if (!run.scheduleId) {
return undefined;
}
const schedule = await prisma.taskSchedule.findFirst({
where: {
id: run.scheduleId,
},
});
if (!schedule) {
return undefined;
}
return {
id: schedule.friendlyId,
externalId: schedule.externalId ?? undefined,
deduplicationKey: schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : undefined,
generator: {
type: "CRON" as const,
expression: schedule.generatorExpression,
description: schedule.generatorDescription,
},
};
}
async function createCommonRunStructure(
run: CommonRelatedRunWithVersion,
apiVersion: API_VERSIONS
) {
const metadata = await parsePacketAsJson({
data: run.metadata ?? undefined,
dataType: run.metadataType,
});
return {
id: run.friendlyId,
taskIdentifier: run.taskIdentifier,
idempotencyKey: getUserProvidedIdempotencyKey(run),
version: run.lockedToVersion?.version,
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion),
createdAt: run.createdAt,
startedAt: run.startedAt ?? undefined,
updatedAt: run.updatedAt,
finishedAt: run.completedAt ?? undefined,
expiredAt: run.expiredAt ?? undefined,
delayedUntil: run.delayUntil ?? undefined,
ttl: run.ttl ?? undefined,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
durationMs: run.usageDurationMs,
isTest: run.isTest,
depth: run.depth,
tags: [...(run.runTags ?? [])].sort((a: string, b: string) => a.localeCompare(b)),
...ApiRetrieveRunPresenter.apiBooleanHelpersFromTaskRunStatus(run.status, apiVersion),
triggerFunction: resolveTriggerFunction(run),
batchId: run.batch?.friendlyId,
metadata,
region: regionForDisplay(run.region, run.workerQueue),
};
}
function resolveTriggerFunction(run: CommonRelatedRun): TriggerFunction {
if (run.batch) {
return run.resumeParentOnCompletion ? "batchTriggerAndWait" : "batchTrigger";
} else {
return run.resumeParentOnCompletion ? "triggerAndWait" : "trigger";
}
}
// Build a FoundRun-shaped object from a buffered (mollified) run. The run
// is in the Redis buffer; engine.trigger hasn't created the Postgres row
// yet, so every field that comes from execution state (output, attempts,
// completedAt, cost, relations) takes a default. The presenter's call()
// handles QUEUED-state runs without surprise.
function bufferedStatusToTaskRunStatus(status: SyntheticRun["status"]): TaskRunStatus {
switch (status) {
case "FAILED":
return "SYSTEM_FAILURE";
case "CANCELED":
return "CANCELED";
default:
return "PENDING";
}
}
// The PG path stores `TaskRun.payload` as `String?`, so in production
// the buffered snapshot's `payload` is always a string. We defensively
// coerce other types instead of silently dropping them: an object gets
// JSON-stringified (matches how the trigger path would serialise it),
// anything truly unrenderable falls back to an empty string. The log
// line surfaces format drift to ops without crashing the read path.
function synthesisePayload(buffered: SyntheticRun): string {
const payload = buffered.payload;
if (typeof payload === "string") return payload;
if (payload === undefined || payload === null) return "";
try {
const serialised = JSON.stringify(payload);
logger.warn("ApiRetrieveRunPresenter: buffered snapshot.payload non-string coerced", {
runFriendlyId: buffered.friendlyId,
payloadType: typeof payload,
});
return typeof serialised === "string" ? serialised : "";
} catch {
logger.error("ApiRetrieveRunPresenter: buffered snapshot.payload unserialisable", {
runFriendlyId: buffered.friendlyId,
payloadType: typeof payload,
});
return "";
}
}
// Mirror synthesisePayload for metadata. The PG path stores
// `TaskRun.metadata` as `String?`, and the snapshot writes it from
// `metadataPacket.data` (also a string), so in production it is always a
// string or absent. We coerce defensively — an object gets JSON-stringified
// (matching how the trigger path serialises it) rather than silently
// dropped to null, and the log line surfaces format drift to ops.
function synthesiseMetadata(buffered: SyntheticRun): string | null {
const metadata = buffered.metadata;
if (typeof metadata === "string") return metadata;
if (metadata === undefined || metadata === null) return null;
try {
const serialised = JSON.stringify(metadata);
logger.warn("ApiRetrieveRunPresenter: buffered snapshot.metadata non-string coerced", {
runFriendlyId: buffered.friendlyId,
metadataType: typeof metadata,
});
return typeof serialised === "string" ? serialised : null;
} catch {
logger.error("ApiRetrieveRunPresenter: buffered snapshot.metadata unserialisable", {
runFriendlyId: buffered.friendlyId,
metadataType: typeof metadata,
});
return null;
}
}
// Exported for unit testing. Used by `findRun()` above when the
// Postgres lookup misses and the buffer carries the run — keep the shape
// in lockstep with `FoundRun`'s field list so `call()` treats a synthesised
// buffered run identically to a freshly-triggered PG row.
export function synthesiseFoundRunFromBuffer(buffered: SyntheticRun): FoundRun {
const status: TaskRunStatus = bufferedStatusToTaskRunStatus(buffered.status);
const errorJson: Prisma.JsonValue = buffered.error
? {
type: "STRING_ERROR",
raw: `${buffered.error.code}: ${buffered.error.message}`,
}
: null;
const metadata: string | null = synthesiseMetadata(buffered);
return {
// `id` is the internal cuid (Prisma TaskRun.id column), `friendlyId`
// is the user-facing `run_xxx` token. Downstream logging keyed off
// `taskRun.id` correlates with other systems via the cuid — using
// the friendlyId here breaks log correlation. `SyntheticRun` carries
// the cuid alongside the friendlyId for exactly this reason
// (RunId.fromFriendlyId in readFallback.server.ts).
id: buffered.id,
friendlyId: buffered.friendlyId,
status,
taskIdentifier: buffered.taskIdentifier ?? "",
createdAt: buffered.createdAt,
startedAt: null,
updatedAt: buffered.cancelledAt ?? buffered.createdAt,
// PG-resident SYSTEM_FAILURE rows always have `completedAt` set by
// the engine; the buffer-synth path must match so SDK consumers
// that poll on `isCompleted` and then read `finishedAt` see a real
// timestamp instead of `undefined`. CANCELED already had this via
// `buffered.cancelledAt`; fall back to `buffered.createdAt` for
// FAILED (the buffer entry has no separate "failedAt" — the
// best-available approximation of when the terminal state landed
// is the entry's creation time).
completedAt: buffered.cancelledAt ?? (status === "SYSTEM_FAILURE" ? buffered.createdAt : null),
expiredAt: null,
delayUntil: buffered.delayUntil ?? null,
metadata,
metadataType: buffered.metadataType ?? "application/json",
ttl: buffered.ttl ?? null,
costInCents: 0,
baseCostInCents: 0,
usageDurationMs: 0,
idempotencyKey: buffered.idempotencyKey ?? null,
idempotencyKeyOptions: buffered.idempotencyKeyOptions ?? null,
isTest: buffered.isTest,
depth: buffered.depth,
// Scheduled triggers go through the same TriggerTaskService path as
// API triggers and aren't bypassed by the mollifier gate, so a
// scheduled run can land in the buffer with its scheduleId set on the
// snapshot. Forward it so resolveSchedule() can hydrate the `schedule`
// field in the API response instead of silently dropping it until the
// drainer materialises.
scheduleId: buffered.scheduleId ?? null,
// commonRunSelect now carries the scalar id; a buffered run has no locked worker yet.
lockedToVersionId: null,
lockedToVersion: buffered.lockedToVersion ? { version: buffered.lockedToVersion } : null,
resumeParentOnCompletion: buffered.resumeParentOnCompletion,
// Reconstruct the batch from the snapshot's internal id so a buffered
// run reports the same `batchId` / triggerFunction as it will once
// materialised, and so batch-scoped JWTs authorise against it (the
// route authorization callbacks read `run.batch?.friendlyId`).
batch: buffered.batchId
? { id: buffered.batchId, friendlyId: BatchId.toFriendlyId(buffered.batchId) }
: null,
runTags: buffered.tags,
traceId: buffered.traceId ?? "",
payload: synthesisePayload(buffered),
payloadType: buffered.payloadType ?? "application/json",
output: null,
outputType: "application/json",
error: errorJson,
attempts: [],
attemptNumber: null,
engine: "V2",
taskEventStore: "taskEvent",
// Empty string when absent (matches syntheticSpanRun.server.ts and lets
// `createCommonRunStructure`'s `run.workerQueue || undefined` coerce the
// API response's `region` to undefined instead of advertising a
// misleading "main" region for a not-yet-assigned buffered run).
workerQueue: buffered.workerQueue ?? "",
region: buffered.region ?? "",
parentTaskRun: null,
rootTaskRun: null,
childRuns: [],
isBuffered: true,
};
}
@@ -0,0 +1,421 @@
import { MachinePresetName, parsePacket, RunStatus } from "@trigger.dev/core/v3";
import { type Project, type RuntimeEnvironment, type TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { z } from "zod";
import type { API_VERSIONS } from "~/api/versions";
import { RunStatusUnspecifiedApiVersion } from "~/api/versions";
import type { PrismaClientOrTransaction } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
import { CoercedDate } from "~/utils/zod";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { ApiRetrieveRunPresenter } from "./ApiRetrieveRunPresenter.server";
import { NextRunListPresenter, type RunListOptions } from "./NextRunListPresenter.server";
import { BasePresenter } from "./basePresenter.server";
// Forwarded verbatim into `NextRunListPresenter` for the routed run-ops (TaskRun) reads. When
// omitted, both clients default to the inherited `_replica` => passthrough single-DB. The
// control-plane `runtimeEnvironment.findMany` env-scoping lookup is never routed.
type ApiRunListPresenterReadThroughDeps = {
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
splitEnabled?: boolean;
};
export const ApiRunListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
"page[after]": z.string().optional(),
"page[before]": z.string().optional(),
"filter[status]": z
.string()
.optional()
.transform((value, ctx) => {
if (!value) {
return undefined;
}
const statuses = value.split(",");
const parsedStatuses = statuses.map((status) =>
RunStatus.or(RunStatusUnspecifiedApiVersion).safeParse(status)
);
if (parsedStatuses.some((result) => !result.success)) {
const invalidStatuses: string[] = [];
for (const [index, result] of parsedStatuses.entries()) {
if (!result.success) {
invalidStatuses.push(statuses[index]);
}
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid status values: ${invalidStatuses.join(", ")}`,
});
return z.NEVER;
}
const $statuses = parsedStatuses
.map((result) => (result.success ? result.data : undefined))
.filter(Boolean);
return Array.from(new Set($statuses));
}),
"filter[env]": z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
"filter[taskIdentifier]": z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
"filter[version]": z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
"filter[tag]": z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
"filter[bulkAction]": z.string().optional(),
"filter[schedule]": z.string().optional(),
// An `error_<fingerprint>` id — lists the runs behind an error group.
"filter[error]": z.string().optional(),
"filter[isTest]": z
.string()
.optional()
.transform((value, ctx) => {
if (!value) {
return undefined;
}
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid value for isTest: ${value}`,
});
return z.NEVER;
}),
"filter[createdAt][from]": CoercedDate,
"filter[createdAt][to]": CoercedDate,
"filter[createdAt][period]": z.string().optional(),
"filter[batch]": z.string().optional(),
"filter[queue]": z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
"filter[region]": z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
"filter[machine]": z
.string()
.optional()
.transform((value, ctx) => {
const values = value ? value.split(",") : undefined;
if (!values) {
return undefined;
}
const parsedValues = values.map((v) => MachinePresetName.safeParse(v));
const invalidValues: string[] = [];
parsedValues.forEach((result, index) => {
if (!result.success) {
invalidValues.push(values[index]);
}
});
if (invalidValues.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid machine values: ${invalidValues.join(", ")}`,
});
return z.NEVER;
}
return parsedValues.map((result) => result.data).filter(Boolean);
}),
});
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
export class ApiRunListPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readThroughDeps?: ApiRunListPresenterReadThroughDeps
) {
super(prismaClient, replicaClient);
}
public async call(
project: Pick<Project, "id">,
searchParams: ApiRunListSearchParams,
apiVersion: API_VERSIONS,
environment?: Pick<RuntimeEnvironment, "id" | "organizationId">
) {
return this.trace("call", async (span) => {
const options: RunListOptions = {
projectId: project.id,
};
// pagination
if (searchParams["page[size]"]) {
options.pageSize = searchParams["page[size]"];
}
if (searchParams["page[after]"]) {
options.cursor = searchParams["page[after]"];
options.direction = "forward";
}
if (searchParams["page[before]"]) {
options.cursor = searchParams["page[before]"];
options.direction = "backward";
}
let environmentId: string | undefined;
let organizationId: string | undefined;
// filters
if (environment) {
environmentId = environment.id;
organizationId = environment.organizationId;
} else {
if (searchParams["filter[env]"]) {
const environments = await this._replica.runtimeEnvironment.findMany({
where: {
projectId: project.id,
slug: {
in: searchParams["filter[env]"],
},
},
});
environmentId = environments.at(0)?.id;
organizationId = environments.at(0)?.organizationId;
}
}
if (!environmentId) {
throw new ServiceValidationError("No environment found");
}
if (!organizationId) {
throw new ServiceValidationError("No organization found");
}
if (searchParams["filter[status]"]) {
options.statuses = searchParams["filter[status]"].flatMap((status) =>
ApiRunListPresenter.apiStatusToRunStatuses(status)
);
}
if (searchParams["filter[taskIdentifier]"]) {
options.tasks = searchParams["filter[taskIdentifier]"];
}
if (searchParams["filter[version]"]) {
options.versions = searchParams["filter[version]"];
}
if (searchParams["filter[tag]"]) {
options.tags = searchParams["filter[tag]"];
}
if (searchParams["filter[bulkAction]"]) {
options.bulkId = searchParams["filter[bulkAction]"];
}
if (searchParams["filter[schedule]"]) {
options.scheduleId = searchParams["filter[schedule]"];
}
if (searchParams["filter[error]"]) {
options.errorId = searchParams["filter[error]"];
}
if (searchParams["filter[createdAt][from]"]) {
options.from = searchParams["filter[createdAt][from]"].getTime();
}
if (searchParams["filter[createdAt][to]"]) {
options.to = searchParams["filter[createdAt][to]"].getTime();
}
if (searchParams["filter[createdAt][period]"]) {
options.period = searchParams["filter[createdAt][period]"];
}
if (typeof searchParams["filter[isTest]"] === "boolean") {
options.isTest = searchParams["filter[isTest]"];
}
if (searchParams["filter[batch]"]) {
options.batchId = searchParams["filter[batch]"];
}
if (searchParams["filter[queue]"]) {
options.queues = searchParams["filter[queue]"];
}
if (searchParams["filter[region]"]) {
options.regions = searchParams["filter[region]"];
}
if (searchParams["filter[machine]"]) {
options.machines = searchParams["filter[machine]"];
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const presenter = new NextRunListPresenter(this._replica, clickhouse, this.readThroughDeps);
logger.debug("Calling RunListPresenter", { options });
const results = await presenter.call(organizationId, environmentId, options);
logger.debug("RunListPresenter results", { runs: results.runs.length });
const data = await Promise.all(
results.runs.map(async (run) => {
const metadata = await parsePacket(
{
data: run.metadata ?? undefined,
dataType: run.metadataType,
},
{
filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"],
}
);
return {
id: run.friendlyId,
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion),
taskIdentifier: run.taskIdentifier,
idempotencyKey: run.idempotencyKey,
version: run.version ?? undefined,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt),
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
isTest: run.isTest,
ttl: run.ttl ?? undefined,
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
env: {
id: run.environment.id,
name: run.environment.slug,
user: run.environment.userName,
},
tags: run.tags,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
durationMs: run.usageDurationMs,
depth: run.depth,
metadata,
// ClickHouse defaults `task_kind` to "" for pre-migration rows.
// Match `NextRunListPresenter`'s "STANDARD" fallback so API
// consumers and the dashboard see the same value.
taskKind: run.taskKind || "STANDARD",
region: run.region ?? undefined,
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion)
),
};
})
);
return {
data,
pagination: {
next: results.pagination.next,
previous: results.pagination.previous,
},
};
});
}
static apiStatusToRunStatuses(
status: RunStatus | RunStatusUnspecifiedApiVersion
): TaskRunStatus[] | TaskRunStatus {
switch (status) {
case "DELAYED":
return "DELAYED";
case "PENDING_VERSION": {
return "PENDING_VERSION";
}
case "WAITING_FOR_DEPLOY": {
return "WAITING_FOR_DEPLOY";
}
case "QUEUED": {
return "PENDING";
}
case "EXECUTING": {
return "EXECUTING";
}
case "REATTEMPTING": {
return "RETRYING_AFTER_FAILURE";
}
case "FROZEN": {
return ["PAUSED", "WAITING_TO_RESUME"];
}
case "CANCELED": {
return "CANCELED";
}
case "COMPLETED": {
return "COMPLETED_SUCCESSFULLY";
}
case "SYSTEM_FAILURE": {
return "SYSTEM_FAILURE";
}
case "INTERRUPTED": {
return "INTERRUPTED";
}
case "CRASHED": {
return "CRASHED";
}
case "FAILED": {
return "COMPLETED_WITH_ERRORS";
}
case "EXPIRED": {
return "EXPIRED";
}
case "TIMED_OUT": {
return "TIMED_OUT";
}
case "DEQUEUED": {
return "DEQUEUED";
}
case "WAITING": {
return "WAITING_TO_RESUME";
}
default: {
assertNever(status);
}
}
}
}
@@ -0,0 +1,63 @@
import type { TaskRunExecutionResult } from "@trigger.dev/core/v3";
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
import { executionResultForTaskRun } from "~/models/taskRun.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { BasePresenter } from "./basePresenter.server";
type ApiRunResultReadThroughDeps = {
splitEnabled?: boolean;
newClient?: PrismaReplicaClient;
// LEGACY RUN-OPS READ REPLICA ONLY (never a writer/primary); defaults to this._replica.
legacyReplica?: PrismaReplicaClient;
isPastRetention?: (runId: string) => boolean;
};
export class ApiRunResultPresenter extends BasePresenter {
constructor(
prisma?: PrismaClientOrTransaction,
replica?: PrismaClientOrTransaction,
private readonly _readThrough?: ApiRunResultReadThroughDeps
) {
super(prisma, replica);
}
public async call(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<TaskRunExecutionResult | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const findRun = (client: PrismaReplicaClient) =>
client.taskRun.findFirst({
where: { friendlyId, runtimeEnvironmentId: env.id },
include: { attempts: { orderBy: { createdAt: "desc" } } },
});
// Single-run result poll routed through run-ops read-through. Split on: primary store first,
// then the secondary read replica for runs that miss on new; past-retention ids return
// undefined -> the route's normal 404. Split off (single-DB / self-host): readThroughRun does
// one plain findFirst against the single client (passthrough).
const result = await readThroughRun({
runId: friendlyId,
environmentId: env.id,
readNew: findRun,
readLegacy: findRun,
deps: {
splitEnabled: this._readThrough?.splitEnabled,
newClient: this._readThrough?.newClient ?? (this._prisma as PrismaReplicaClient),
legacyReplica: this._readThrough?.legacyReplica ?? (this._replica as PrismaReplicaClient),
isPastRetention: this._readThrough?.isPastRetention,
},
});
const taskRun =
result.source === "new" || result.source === "legacy-replica" ? result.value : undefined;
if (!taskRun) {
return undefined;
}
return executionResultForTaskRun(taskRun);
});
}
}
@@ -0,0 +1,144 @@
import { type RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3";
import { type RunEngineVersion } from "@trigger.dev/database";
import { z } from "zod";
import { type PrismaClientOrTransaction } from "~/db.server";
import { CoercedDate } from "~/utils/zod";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { BasePresenter } from "./basePresenter.server";
import { type WaitpointListOptions, WaitpointListPresenter } from "./WaitpointListPresenter.server";
export const ApiWaitpointListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
"page[after]": z.string().optional(),
"page[before]": z.string().optional(),
"filter[status]": z
.string()
.optional()
.transform((value, ctx) => {
if (!value) {
return undefined;
}
const statuses = value.split(",");
const parsedStatuses = statuses.map((status) => WaitpointTokenStatus.safeParse(status));
if (parsedStatuses.some((result) => !result.success)) {
const invalidStatuses: string[] = [];
for (const [index, result] of parsedStatuses.entries()) {
if (!result.success) {
invalidStatuses.push(statuses[index]);
}
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid status values: ${invalidStatuses.join(", ")}`,
});
return z.NEVER;
}
const $statuses = parsedStatuses
.map((result) => (result.success ? result.data : undefined))
.filter(Boolean);
return Array.from(new Set($statuses));
}),
"filter[idempotencyKey]": z.string().optional(),
"filter[tags]": z
.string()
.optional()
.transform((value) => {
if (!value) return undefined;
return value.split(",");
}),
"filter[createdAt][period]": z.string().optional(),
"filter[createdAt][from]": CoercedDate,
"filter[createdAt][to]": CoercedDate,
});
type ApiWaitpointListSearchParams = z.infer<typeof ApiWaitpointListSearchParams>;
export class ApiWaitpointListPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction;
runOpsLegacyReplica?: PrismaClientOrTransaction;
splitEnabled?: boolean;
}
) {
super(prismaClient, replicaClient);
}
public async call(
environment: {
id: string;
type: RuntimeEnvironmentType;
project: {
id: string;
engine: RunEngineVersion;
};
apiKey: string;
},
searchParams: ApiWaitpointListSearchParams
) {
return this.trace("call", async (span) => {
const options: WaitpointListOptions = {
environment,
};
if (searchParams["page[size]"]) {
options.pageSize = searchParams["page[size]"];
}
if (searchParams["page[after]"]) {
options.cursor = searchParams["page[after]"];
options.direction = "forward";
}
if (searchParams["page[before]"]) {
options.cursor = searchParams["page[before]"];
options.direction = "backward";
}
if (searchParams["filter[status]"]) {
options.statuses = searchParams["filter[status]"];
}
if (searchParams["filter[idempotencyKey]"]) {
options.idempotencyKey = searchParams["filter[idempotencyKey]"];
}
if (searchParams["filter[tags]"]) {
options.tags = searchParams["filter[tags]"];
}
if (searchParams["filter[createdAt][period]"]) {
options.period = searchParams["filter[createdAt][period]"];
}
if (searchParams["filter[createdAt][from]"]) {
options.from = searchParams["filter[createdAt][from]"].getTime();
}
if (searchParams["filter[createdAt][to]"]) {
options.to = searchParams["filter[createdAt][to]"].getTime();
}
const presenter = new WaitpointListPresenter(undefined, undefined, this.readRoute);
const result = await presenter.call(options);
if (!result.success) {
throw new ServiceValidationError(result.error);
}
return {
data: result.tokens,
pagination: result.pagination,
};
});
}
}
@@ -0,0 +1,124 @@
import { logger, type RuntimeEnvironmentType } from "@trigger.dev/core/v3";
import { type RunEngineVersion } from "@trigger.dev/database";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { BasePresenter } from "./basePresenter.server";
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
// When omitted, clients default to the inherited _replica handle => passthrough reads the
// replica exactly as today. isPastRetention is injectable for tests. Typed PrismaReplicaClient
// to match readThroughRun's readNew/readLegacy + deps.
type ApiWaitpointPresenterReadThroughDeps = {
newClient?: PrismaReplicaClient;
legacyReplica?: PrismaReplicaClient;
splitEnabled?: boolean;
isPastRetention?: (id: string) => boolean;
};
export class ApiWaitpointPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readThroughDeps?: ApiWaitpointPresenterReadThroughDeps
) {
super(prismaClient, replicaClient);
}
public async call(
environment: {
id: string;
type: RuntimeEnvironmentType;
project: {
id: string;
engine: RunEngineVersion;
};
apiKey: string;
},
waitpointId: string
) {
return this.trace("call", async (span) => {
// Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY
// RUN-OPS READ REPLICA ONLY on a new-probe miss — never the legacy primary.
// Split off (single-DB / self-host): one plain waitpoint.findFirst against the replica
// (passthrough). The waitpointId is the residency-classifiable run-ops id (the route
// pre-decodes the friendlyId via WaitpointId.toId).
const hydrate = (client: PrismaReplicaClient) =>
client.waitpoint.findFirst({
where: {
id: waitpointId,
environmentId: environment.id,
},
select: {
id: true,
friendlyId: true,
type: true,
status: true,
idempotencyKey: true,
userProvidedIdempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
output: true,
outputType: true,
outputIsError: true,
completedAfter: true,
completedAt: true,
createdAt: true,
tags: true,
},
});
const result = await readThroughRun({
runId: waitpointId,
environmentId: environment.id,
readNew: (client) => hydrate(client),
readLegacy: (replica) => hydrate(replica),
deps: {
splitEnabled: this.readThroughDeps?.splitEnabled,
// Default both clients to the inherited _replica handle (declared
// PrismaClientOrTransaction but $replica at runtime) so passthrough reads the replica
// as today; split mode injects a distinct newClient.
newClient: this.readThroughDeps?.newClient ?? (this._replica as PrismaReplicaClient),
legacyReplica:
this.readThroughDeps?.legacyReplica ?? (this._replica as PrismaReplicaClient),
isPastRetention: this.readThroughDeps?.isPastRetention,
},
});
const waitpoint =
result.source === "new" || result.source === "legacy-replica" ? result.value : null;
if (!waitpoint) {
logger.error(`WaitpointPresenter: Waitpoint not found`, {
id: waitpointId,
});
throw new ServiceValidationError("Waitpoint not found");
}
let _isTimeout = false;
if (waitpoint.outputIsError && waitpoint.output) {
_isTimeout = true;
}
return {
id: waitpoint.friendlyId,
type: waitpoint.type,
url: generateHttpCallbackUrl(waitpoint.id, environment.apiKey),
status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError),
idempotencyKey: waitpoint.idempotencyKey,
userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey,
idempotencyKeyExpiresAt: waitpoint.idempotencyKeyExpiresAt ?? undefined,
inactiveIdempotencyKey: waitpoint.inactiveIdempotencyKey ?? undefined,
output: waitpoint.output ?? undefined,
outputType: waitpoint.outputType,
outputIsError: waitpoint.outputIsError,
timeoutAt: waitpoint.completedAfter ?? undefined,
completedAfter: waitpoint.completedAfter ?? undefined,
completedAt: waitpoint.completedAt ?? undefined,
createdAt: waitpoint.createdAt,
tags: waitpoint.tags,
};
});
}
}
@@ -0,0 +1,313 @@
import { type BatchTaskRunStatus } from "@trigger.dev/database";
import parse from "parse-duration";
import { type PrismaClientOrTransaction } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { BasePresenter } from "./basePresenter.server";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
export type BatchListOptions = {
userId?: string;
projectId: string;
environmentId: string;
//filters
friendlyId?: string;
statuses?: BatchTaskRunStatus[];
period?: string;
from?: number;
to?: number;
//pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type BatchList = Awaited<ReturnType<BatchListPresenter["call"]>>;
export type BatchListItem = BatchList["batches"][0];
export type BatchListAppliedFilters = BatchList["filters"];
// The row shape of the raw BatchTaskRun keyset scan. Extracted to a named type so the
// store-selected scan closure and the keyset merge in `#scanBatchTaskRun` can reference it.
type BatchRow = {
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
status: BatchTaskRunStatus;
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
runCount: number;
batchVersion: string;
};
export class BatchListPresenter extends BasePresenter {
// Optional run-ops read-routing. Omitted (single-DB / self-host) => everything
// reads from `_replica` exactly as today (passthrough). Field names are local to
// this presenter; only the read-routing convention (optional handles, default-to-_replica,
// boot-constant splitEnabled) is mirrored, not the literal RunsRepositoryOptions names.
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction; // new run-ops client
runOpsLegacyReplica?: PrismaClientOrTransaction; // legacy run-ops READ REPLICA only — never the legacy primary
controlPlaneReplica?: PrismaClientOrTransaction; // control-plane DB (for project)
splitEnabled?: boolean; // resolved boot constant
}
) {
super(prismaClient, replicaClient);
}
// Control-plane READ handle for the `project` lookup. In single-DB / when omitted this is
// `_replica` ⇒ unchanged.
get #controlPlaneReplica(): PrismaClientOrTransaction {
return this.readRoute?.controlPlaneReplica ?? this._replica;
}
// Run-ops reads for the Batches dashboard. Split on: new run-ops DB first; the LEGACY
// RUN-OPS READ REPLICA ONLY for the older not-yet-migrated remainder/empty-state — never the
// legacy primary. Split off (single-DB / self-host): one plain `_replica` read (passthrough).
// `project` is resolved on the control-plane DB; the environment↔batch join is in-memory (no
// cross-seam SQL join).
async #scanBatchTaskRun(
pageSize: number,
direction: Direction,
scan: (client: PrismaClientOrTransaction) => Promise<BatchRow[]>
): Promise<BatchRow[]> {
if (!this.readRoute?.splitEnabled) {
return scan(this._replica);
}
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
// New DB filled the page — skip the legacy read entirely; older rows fall on a later page.
if (newRows.length >= pageSize + 1) {
return newRows;
}
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? this._replica);
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch
// LIMIT — reproduces the pageSize+1 window a single union scan would return.
const byId = new Map<string, BatchRow>();
for (const row of newRows) {
byId.set(row.id, row);
}
for (const row of legacyRows) {
if (!byId.has(row.id)) {
byId.set(row.id, row);
}
}
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or run-ops id).
const sign = direction === "forward" ? 1 : -1; // forward => DESC; backward => ASC
return Array.from(byId.values())
.sort((a, b) => (a.id < b.id ? sign : a.id > b.id ? -sign : 0))
.slice(0, pageSize + 1);
}
// Empty-state probe. Split on: probe the new run-ops DB first, then the legacy READ REPLICA only
// (never the legacy primary). Split off (single-DB / self-host): one plain `_replica` probe.
async #probeAnyBatch(environmentId: string): Promise<boolean> {
// Passthrough: probe the SAME client the scan uses (_replica), or the empty-state hint can
// disagree with the page when a run-ops DB is configured but read-split is off.
if (!this.readRoute?.splitEnabled) {
const onReplica = await this._replica.batchTaskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
});
return Boolean(onReplica);
}
const onNew = await (this.readRoute.runOpsNew ?? this._replica).batchTaskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
});
if (onNew) {
return true;
}
const onLegacy = await (
this.readRoute.runOpsLegacyReplica ?? this._replica
).batchTaskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
});
return Boolean(onLegacy);
}
public async call({
userId,
projectId,
friendlyId,
statuses,
environmentId,
period,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: BatchListOptions) {
//get the time values from the raw values (including a default period)
const time = timeFilters({
period,
from,
to,
});
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters = hasStatusFilters || friendlyId !== undefined || !time.isDefault;
const project = await this.#controlPlaneReplica.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
id: projectId,
},
});
const periodMs = time.period ? parse(time.period) : undefined;
let createdAtGte: Date | undefined;
if (periodMs != null) {
createdAtGte = new Date(Date.now() - periodMs);
}
if (time.from !== undefined) {
createdAtGte =
createdAtGte === undefined
? time.from
: time.from > createdAtGte
? time.from
: createdAtGte;
}
const createdAtLte: Date | undefined = time.to;
const batches = await this.#scanBatchTaskRun(pageSize, direction, (client) =>
client.batchTaskRun.findMany({
where: {
runtimeEnvironmentId: environmentId,
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
...(friendlyId ? { friendlyId } : {}),
...(statuses && statuses.length > 0
? { status: { in: statuses }, batchVersion: { not: "v1" } }
: {}),
...(createdAtGte !== undefined || createdAtLte !== undefined
? {
createdAt: {
...(createdAtGte !== undefined ? { gte: createdAtGte } : {}),
...(createdAtLte !== undefined ? { lte: createdAtLte } : {}),
},
}
: {}),
},
orderBy: { id: direction === "forward" ? "desc" : "asc" },
take: pageSize + 1,
select: {
id: true,
friendlyId: true,
runtimeEnvironmentId: true,
status: true,
createdAt: true,
updatedAt: true,
completedAt: true,
runCount: true,
batchVersion: true,
},
})
);
const hasMore = batches.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? batches.at(0)?.id : undefined;
if (hasMore) {
next = batches[pageSize - 1]?.id;
}
break;
case "backward":
batches.reverse();
if (hasMore) {
previous = batches[1]?.id;
next = batches[pageSize]?.id;
} else {
next = batches[pageSize - 1]?.id;
}
break;
}
const batchesToReturn =
direction === "backward" && hasMore
? batches.slice(1, pageSize + 1)
: batches.slice(0, pageSize);
let hasAnyBatches = batchesToReturn.length > 0;
if (!hasAnyBatches) {
hasAnyBatches = await this.#probeAnyBatch(environmentId);
}
return {
batches: batchesToReturn.map((batch) => {
const environment = project.environments.find(
(env) => env.id === batch.runtimeEnvironmentId
);
if (!environment) {
throw new Error(`Environment not found for Batch ${batch.id}`);
}
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
return {
id: batch.id,
friendlyId: batch.friendlyId,
createdAt: batch.createdAt.toISOString(),
updatedAt: batch.updatedAt.toISOString(),
hasFinished,
finishedAt: batch.completedAt
? batch.completedAt.toISOString()
: hasFinished
? batch.updatedAt.toISOString()
: undefined,
status: batch.status,
environment: displayableEnvironment(environment, userId),
runCount: Number(batch.runCount),
batchVersion: batch.batchVersion,
};
}),
pagination: {
next,
previous,
},
filters: {
friendlyId,
statuses: statuses || [],
},
hasFilters,
hasAnyBatches,
};
}
}
@@ -0,0 +1,155 @@
import { type BatchTaskRunStatus, type Prisma } from "@trigger.dev/database";
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { engine } from "~/v3/runEngine.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { BasePresenter } from "./basePresenter.server";
type BatchPresenterOptions = {
environmentId: string;
batchId: string;
userId?: string;
};
// Shared by the read-through closures and the passthrough so every store path returns
// a byte-identical row shape.
const BATCH_SELECT = {
id: true,
friendlyId: true,
status: true,
runCount: true,
batchVersion: true,
createdAt: true,
updatedAt: true,
completedAt: true,
processingStartedAt: true,
processingCompletedAt: true,
successfulRunCount: true,
failedRunCount: true,
idempotencyKey: true,
errors: {
select: {
id: true,
index: true,
taskIdentifier: true,
error: true,
errorCode: true,
createdAt: true,
},
orderBy: {
index: "asc",
},
},
} satisfies Prisma.BatchTaskRunSelect;
type BatchRow = Prisma.BatchTaskRunGetPayload<{ select: typeof BATCH_SELECT }>;
type BatchPresenterDeps = {
/** Resolved boot constant; never awaited per-request when supplied. */
splitEnabled?: boolean;
newClient?: PrismaReplicaClient;
legacyReplica?: PrismaReplicaClient;
readThrough?: typeof readThroughRun;
resolveDisplayableEnvironment?: typeof findDisplayableEnvironment;
};
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
export class BatchPresenter extends BasePresenter {
constructor(
_prisma?: PrismaClientOrTransaction,
_replica?: PrismaClientOrTransaction,
private readonly deps: BatchPresenterDeps = {}
) {
super(_prisma, _replica);
}
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
// Reads the BatchTaskRun (run-ops) via the read-through layer: split on -> new run-ops
// first, then the LEGACY RUN-OPS READ REPLICA only for not-yet-migrated batches (never the
// legacy primary); split off (single-DB / self-host) -> one plain batchTaskRun.findFirst
// (passthrough). The runtimeEnvironment (control-plane) is resolved separately because its
// FK is physically dropped on cloud, so a batch row on the new run-ops DB cannot single-SQL
// join to control-plane RuntimeEnvironment.
const where = { runtimeEnvironmentId: environmentId, friendlyId: batchId } as const;
const readBatch = (client: PrismaReplicaClient): Promise<BatchRow | null> =>
client.batchTaskRun.findFirst({ select: BATCH_SELECT, where });
const readThrough = this.deps.readThrough ?? readThroughRun;
const batchResult = await readThrough<BatchRow>({
// The read-through key; here it is the batch friendlyId. A cuid-shaped batch friendlyId
// classifies as LEGACY and the read-through probes both stores (new first, then legacy
// replica); a run-ops-shaped one (cut-over orgs) classifies as NEW and reads only the new
// store — either way the row is found on the DB that owns it.
runId: batchId,
environmentId,
readNew: readBatch,
readLegacy: readBatch,
deps: {
splitEnabled: this.deps.splitEnabled,
newClient: this.deps.newClient,
legacyReplica: this.deps.legacyReplica,
},
});
const batch =
batchResult.source === "new" || batchResult.source === "legacy-replica"
? batchResult.value
: null; // not-found / past-retention => normal not-found surface
if (!batch) {
throw new Error("Batch not found");
}
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
const isV2 = batch.batchVersion === "runengine:v2";
// For v2 batches in PROCESSING state, get live progress from Redis
// This provides real-time updates without waiting for the batch to complete
let liveSuccessCount = batch.successfulRunCount ?? 0;
let liveFailureCount = batch.failedRunCount ?? 0;
if (isV2 && batch.status === "PROCESSING") {
const liveProgress = await engine.getBatchQueueProgress(batch.id);
if (liveProgress) {
liveSuccessCount = liveProgress.successCount;
liveFailureCount = liveProgress.failureCount;
}
}
// Control-plane env resolved separately from the run-ops batch row (cross-seam FK dropped).
const resolveEnv = this.deps.resolveDisplayableEnvironment ?? findDisplayableEnvironment;
return {
id: batch.id,
friendlyId: batch.friendlyId,
status: batch.status as BatchTaskRunStatus,
runCount: batch.runCount,
batchVersion: batch.batchVersion,
isV2,
createdAt: batch.createdAt.toISOString(),
updatedAt: batch.updatedAt.toISOString(),
completedAt: batch.completedAt?.toISOString(),
processingStartedAt: batch.processingStartedAt?.toISOString(),
processingCompletedAt: batch.processingCompletedAt?.toISOString(),
finishedAt: batch.completedAt
? batch.completedAt.toISOString()
: hasFinished
? batch.updatedAt.toISOString()
: undefined,
hasFinished,
successfulRunCount: liveSuccessCount,
failedRunCount: liveFailureCount,
idempotencyKey: batch.idempotencyKey,
environment: await resolveEnv(environmentId, userId),
errors: batch.errors.map((error) => ({
id: error.id,
index: error.index,
taskIdentifier: error.taskIdentifier,
error: error.error,
errorCode: error.errorCode,
createdAt: error.createdAt.toISOString(),
})),
};
}
}
@@ -0,0 +1,355 @@
import { GitMeta } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import { type z } from "zod";
import { type Prisma, type PrismaClient, prisma } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { type BranchesOptions } from "~/utils/branches";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { checkBranchLimit } from "~/services/upsertBranch.server";
import { devPresence } from "./DevPresence.server";
import { sortEnvironments } from "~/utils/environmentSort";
import {
type BranchableEnvironmentToken,
type BranchableEnvironmentType,
toBranchableEnvironmentType,
} from "~/utils/branchableEnvironment";
type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
export type Branch = Result["branches"][number];
const BRANCHES_PER_PAGE = 25;
/**
* Prisma `where` fragment that scopes the branches list by branch name, keyed by
* environment type. Spread it into the query's `where` (it contributes either a
* `branchName` constraint or a top-level `OR`).
*
* The default DEV branch is the root dev env, stored with `branchName: null`, so
* for DEVELOPMENT we always include the null-branchName root (and still match it
* when searching — hence the top-level `OR`, since a scalar field filter can't
* express "matches search OR is null"). PREVIEW only ever lists real branches, so
* its root (null) is excluded. Passing no `search` yields the "all branches of
* this type" fragment.
*/
function branchNameFilter(
envType: BranchableEnvironmentType,
search?: string
): Prisma.RuntimeEnvironmentWhereInput {
switch (envType) {
case "DEVELOPMENT":
return search
? { OR: [{ branchName: { contains: search, mode: "insensitive" } }, { branchName: null }] }
: {};
case "PREVIEW":
return search
? { branchName: { contains: search, mode: "insensitive" } }
: { branchName: { not: null } };
default:
throw new Error(`branchNameFilter: unsupported environment type "${envType}"`);
}
}
type Options = z.infer<typeof BranchesOptions>;
export type GitMetaLinks = {
/** The cleaned repository URL without any username/password */
repositoryUrl: string;
/** The branch name */
branchName: string;
/** Link to the specific branch */
branchUrl: string;
/** Link to the specific commit */
commitUrl: string;
/** Link to the pull request (if available) */
pullRequestUrl?: string;
/** The pull request number (if available) */
pullRequestNumber?: number;
/** The pull request title (if available) */
pullRequestTitle?: string;
/** Link to compare this branch with main */
compareUrl: string;
/** Shortened commit SHA (first 7 characters) */
shortSha: string;
/** Whether the branch has uncommitted changes */
isDirty: boolean;
/** The commit message */
commitMessage: string;
/** The commit author */
commitAuthor: string;
/** The git provider, e.g., `github` */
provider?: string;
source?: "trigger_github_app" | "github_actions" | "local";
ghUsername?: string;
ghUserAvatarUrl?: string;
};
export class BranchesPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
env,
showArchived = false,
search,
page = 1,
}: {
userId: User["id"];
projectSlug: Project["slug"];
env: BranchableEnvironmentToken;
} & Options) {
const project = await this.#prismaClient.project.findFirst({
select: {
id: true,
organizationId: true,
},
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
throw new Error("Project not found");
}
const envType = toBranchableEnvironmentType(env);
const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: project.id,
type: envType,
// The branchable parent is the root env (no parent). For dev that's
// derivable; for preview we trust the isBranchableEnvironment column.
...(envType === "DEVELOPMENT"
? { parentEnvironmentId: null, orgMember: { userId } }
: { isBranchableEnvironment: true }),
},
});
const hasFilters = !!showArchived || (search !== undefined && search !== "");
if (!branchableEnvironment) {
if (envType === "DEVELOPMENT") {
throw new Error("No branchable environment in development environment");
}
return {
branchableEnvironment: null,
currentPage: page,
totalPages: 0,
hasBranches: false,
branches: [],
hasFilters: false,
limits: {
used: 0,
limit: 0,
isAtLimit: true,
},
canPurchaseBranches: false,
extraBranches: 0,
branchPricing: null,
maxBranchQuota: 0,
planBranchLimit: 0,
};
}
const branchNameWhere = branchNameFilter(envType, search);
const orgMemberWhere = envType === "DEVELOPMENT" ? { orgMember: { userId } } : {};
const visibleCount = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
type: envType,
...branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
});
const limits = await checkBranchLimit({
prisma: this.#prismaClient,
organizationId: project.organizationId,
projectId: project.id,
userId,
type: envType,
});
const [currentPlan, plans] = await Promise.all([
getCurrentPlan(project.organizationId),
getPlans(),
]);
const canPurchaseBranches =
currentPlan?.v3Subscription?.plan?.limits.branches.canExceed === true;
const extraBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0;
const maxBranchQuota = currentPlan?.v3Subscription?.addOns?.branches?.quota ?? 0;
const planBranchLimit = currentPlan?.v3Subscription?.plan?.limits.branches.number ?? 0;
const branchPricing = plans?.addOnPricing.branches ?? null;
const branches = await this.#prismaClient.runtimeEnvironment.findMany({
select: {
id: true,
slug: true,
branchName: true,
parentEnvironmentId: true,
type: true,
archivedAt: true,
createdAt: true,
updatedAt: true,
git: true,
},
where: {
projectId: project.id,
type: envType,
...branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
orderBy: {
branchName: "asc",
},
skip: (page - 1) * BRANCHES_PER_PAGE,
take: BRANCHES_PER_PAGE,
});
const totalBranches = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
type: envType,
...branchNameFilter(envType),
...orgMemberWhere,
},
});
const branchesFiltered = branches
.filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null)
.map((branch) => ({
...branch,
git: processGitMetadata(branch.git),
branchName: branch.branchName ?? DEFAULT_DEV_BRANCH,
}));
const branchesWithActivity = await hydrateEnvsWithActivity(
userId,
project.id,
branchesFiltered
);
const branchesSorted = sortEnvironments(branchesWithActivity);
return {
branchableEnvironment,
currentPage: page,
totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE),
hasBranches: totalBranches > 0,
branches: branchesSorted,
hasFilters,
limits,
canPurchaseBranches,
extraBranches,
branchPricing,
maxBranchQuota,
planBranchLimit,
};
}
}
export async function hydrateEnvsWithActivity<
T extends { type: RuntimeEnvironmentType; id: string },
>(
userId: string,
projectId: string,
environments: T[]
): Promise<Array<T & { lastActivity: Date | undefined; isConnected: boolean | undefined }>> {
const recentDevBranchIds = await devPresence.getRecentBranchIds(userId, projectId);
const devEnvIds = environments
.filter((env) => env.type === "DEVELOPMENT" && recentDevBranchIds.has(env.id))
.map((env) => env.id);
const connectedMap = await devPresence.isConnectedMany(devEnvIds);
return environments.map((env) => {
if (env.type !== "DEVELOPMENT") {
return { ...env, lastActivity: undefined, isConnected: undefined };
}
const devHit = recentDevBranchIds.get(env.id);
const lastActivity = devHit === undefined ? undefined : devHit;
const isConnected = devHit === undefined ? undefined : (connectedMap.get(env.id) ?? false);
return { ...env, lastActivity, isConnected };
});
}
export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null {
if (!data) return null;
const parsed = GitMeta.safeParse(data);
if (!parsed.success) {
return null;
}
if (!parsed.data.remoteUrl) {
return null;
}
// Clean the remote URL by removing any username/password and ensuring it's a proper GitHub URL
const cleanRemoteUrl = (() => {
try {
const url = new URL(parsed.data.remoteUrl);
// Remove any username/password from the URL
url.username = "";
url.password = "";
// Ensure we're using https
url.protocol = "https:";
// Remove any trailing .git
return url.toString().replace(/\.git$/, "");
} catch (_e) {
// If URL parsing fails, try to clean it manually
return parsed.data.remoteUrl
.replace(/^git@github\.com:/, "https://github.com/")
.replace(/^https?:\/\/[^@]+@/, "https://")
.replace(/\.git$/, "");
}
})();
if (!parsed.data.commitRef || !parsed.data.commitSha) return null;
const shortSha = parsed.data.commitSha.slice(0, 7);
return {
repositoryUrl: cleanRemoteUrl,
branchName: parsed.data.commitRef,
branchUrl: `${cleanRemoteUrl}/tree/${parsed.data.commitRef}`,
commitUrl: `${cleanRemoteUrl}/commit/${parsed.data.commitSha}`,
pullRequestUrl: parsed.data.pullRequestNumber
? `${cleanRemoteUrl}/pull/${parsed.data.pullRequestNumber}`
: undefined,
pullRequestNumber: parsed.data.pullRequestNumber,
pullRequestTitle: parsed.data.pullRequestTitle,
compareUrl: `${cleanRemoteUrl}/compare/main...${parsed.data.commitRef}`,
shortSha,
isDirty: parsed.data.dirty ?? false,
commitMessage: parsed.data.commitMessage ?? "",
commitAuthor: parsed.data.commitAuthorName ?? "",
provider: parsed.data.provider,
source: parsed.data.source,
ghUsername: parsed.data.ghUsername,
ghUserAvatarUrl: parsed.data.ghUserAvatarUrl,
};
}
@@ -0,0 +1,566 @@
import { type BuiltInDashboard } from "./MetricDashboardPresenter.server";
const overviewDashboard: BuiltInDashboard = {
key: "overview",
title: "Run metrics",
filters: ["tasks", "queues"],
layout: {
version: "1",
layout: [
{ i: "9lDDdebQ", x: 3, y: 0, w: 3, h: 4 },
{ i: "VhAgNlB0", x: 0, y: 0, w: 3, h: 4 },
{ i: "iI5EnhJW", x: 6, y: 0, w: 3, h: 4 },
{ i: "HtSgJEmp", x: 0, y: 17, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "rRbzv-Aq", x: 6, y: 4, w: 6, h: 13 },
{ i: "j3yFSxLM", x: 0, y: 33, w: 6, h: 11 },
{ i: "IKB8cENo", x: 6, y: 33, w: 6, h: 11 },
{ i: "-fHz3CyQ", x: 0, y: 56, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "hnKsN482", x: 0, y: 58, w: 12, h: 15 },
{ i: "if6dds8T", x: 0, y: 19, w: 12, h: 14 },
{ i: "i3q1Awfz", x: 0, y: 4, w: 6, h: 13 },
{ i: "Kh0w0fjy", x: 6, y: 44, w: 6, h: 12 },
{ i: "zybRTAdz", x: 0, y: 44, w: 6, h: 12 },
{ i: "ff2nVxxt", x: 0, y: 73, w: 12, h: 15 },
{ i: "Dib0ywb4", x: 0, y: 88, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "YsWiQENd", x: 0, y: 90, w: 12, h: 15 },
{ i: "lc-guCvo", x: 0, y: 105, w: 12, h: 15 },
{ i: "xyQl3FAd", x: 9, y: 0, w: 3, h: 4 },
],
widgets: {
"9lDDdebQ": {
title: "Total runs",
query: "SELECT\r\n count() AS total_runs\r\nFROM\r\n runs",
display: { type: "bignumber", column: "total_runs", aggregation: "sum", abbreviate: false },
},
VhAgNlB0: {
title: "Success %",
query:
"SELECT\r\n round(countIf (status = 'Completed') * 100.0 / countIf (is_finished = 1), 2) AS success_percentage\r\nFROM\r\n runs",
display: {
type: "bignumber",
column: "success_percentage",
aggregation: "avg",
abbreviate: true,
suffix: "%",
},
},
iI5EnhJW: {
title: "Failed runs",
query:
"SELECT\r\n count() AS total_runs\r\nFROM\r\n runs\r\nWHERE status IN ('Failed', 'System failure', 'Crashed')",
display: { type: "bignumber", column: "total_runs", aggregation: "sum", abbreviate: false },
},
HtSgJEmp: { title: "Failed runs", query: "", display: { type: "title" } },
"rRbzv-Aq": {
title: "Runs by status",
query:
"SELECT\r\n timeBucket (),\r\n status,\r\n count() AS run_count\r\nFROM\r\n runs\r\nGROUP BY\r\n timeBucket,\r\n status\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["run_count"],
groupByColumn: "status",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
j3yFSxLM: {
title: "Top failing tasks",
query:
"SELECT\r\n task_identifier AS task,\r\n count() AS runs,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) AS failures,\r\n concat(round((countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) * 100, 2), '%') AS failure_rate,\r\n avg(attempt_count - 1) AS avg_retries\r\nFROM\r\n runs\r\nGROUP BY\r\n task_identifier\r\nORDER BY\r\n (countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) DESC;",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
IKB8cENo: {
title: "Top failing tags",
query:
"SELECT\r\n arrayJoin(tags) AS tag,\r\n count() AS runs,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) AS failures,\r\n concat(round((countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) * 100, 2), '%') AS failure_rate,\r\n avg(attempt_count - 1) AS avg_retries\r\nFROM\r\n runs\r\nGROUP BY\r\n tag\r\nORDER BY\r\n (countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) DESC;",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"-fHz3CyQ": { title: "Usage and cost", query: "", display: { type: "title" } },
hnKsN482: {
title: "Cost by task",
query:
"SELECT\r\n timeBucket() as time_period,\r\n task_identifier,\r\n sum(total_cost) AS total_cost\r\nFROM\r\n runs\r\nGROUP BY\r\n time_period,\r\n task_identifier\r\nORDER BY\r\n time_period",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "time_period",
yAxisColumns: ["total_cost"],
groupByColumn: "task_identifier",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
if6dds8T: {
title: "Failed runs by task",
query:
"SELECT\r\n timeBucket () as time_period,\r\n task_identifier,\r\n count() AS run_count\r\nFROM\r\n runs\r\nWHERE status IN ('Failed', 'Crashed', 'System failure')\r\nGROUP BY\r\n time_period,\r\n task_identifier\r\nORDER BY\r\n time_period",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "time_period",
yAxisColumns: ["run_count"],
groupByColumn: "task_identifier",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
i3q1Awfz: {
title: "Run success %",
query:
"SELECT\r\n timeBucket (),\r\n count() as total,\r\n countIf (status = 'Completed') / total * 100 AS completed,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) / total * 100 AS failed,\r\nFROM\r\n runs\r\nGROUP BY\r\n timeBucket\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["failed", "completed"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
seriesColors: { failed: "#f43f5e" },
},
},
Kh0w0fjy: {
title: "Top errors",
query:
"SELECT\r\n concat(error.name, '(\"', error.message, '\")') AS error,\r\n count() AS count\r\nFROM\r\n runs\r\nWHERE\r\n runs.error != NULL\r\n AND runs.error.name != NULL\r\nGROUP BY\r\n error\r\nORDER BY\r\n count DESC",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
zybRTAdz: {
title: "Top errors over time",
query:
"SELECT\r\n timeBucket(),\r\n concat(error.name, '(\"', error.message, '\")') AS error,\r\n count() AS count\r\nFROM\r\n runs\r\nWHERE\r\n runs.error != NULL\r\n AND runs.error.name != NULL\r\nGROUP BY\r\n timeBucket,\r\n error\r\nORDER BY\r\n count DESC",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["count"],
groupByColumn: "error",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: { count: "#ef4343" },
},
},
ff2nVxxt: {
title: "Cost by machine",
query:
"SELECT\r\n timeBucket() as time_period,\r\n machine,\r\n sum(total_cost) AS total_cost\r\nFROM\r\n runs\r\nWHERE machine != ''\r\nGROUP BY\r\n time_period,\r\n machine\r\nORDER BY\r\n time_period",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "time_period",
yAxisColumns: ["total_cost"],
groupByColumn: "machine",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
Dib0ywb4: { title: "Versions", query: "", display: { type: "title" } },
YsWiQENd: {
title: "Runs by version",
query:
"SELECT\r\n timeBucket (),\r\n task_version,\r\n count() as runs\r\nFROM\r\n runs\r\nWHERE task_version != ''\r\nGROUP BY\r\n timeBucket,\r\n task_version\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["runs"],
groupByColumn: "task_version",
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
},
},
"lc-guCvo": {
title: "Version success %",
query:
"SELECT\r\n timeBucket (),\r\n task_version,\r\n count() as total,\r\n countIf (status = 'Completed') / total * 100 AS success\r\nFROM\r\n runs\r\nWHERE task_version != ''\r\nGROUP BY\r\n timeBucket,\r\n task_version\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["success"],
groupByColumn: "task_version",
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
seriesColors: {},
},
},
xyQl3FAd: {
title: "Queued",
query:
"SELECT\r\n count() AS queued\r\nFROM\r\n runs\r\nWHERE status IN ('Dequeued', 'Queued')",
display: { type: "bignumber", column: "queued", aggregation: "sum", abbreviate: false },
},
},
},
};
const llmDashboard: BuiltInDashboard = {
key: "llm",
title: "AI metrics",
filters: ["tasks", "models", "prompts", "operations", "providers"],
layout: {
version: "1",
layout: [
// Big numbers row
{ i: "llm-cost", x: 0, y: 0, w: 3, h: 4 },
{ i: "llm-calls", x: 3, y: 0, w: 3, h: 4 },
{ i: "llm-ttfc", x: 6, y: 0, w: 3, h: 4 },
{ i: "llm-tps", x: 9, y: 0, w: 3, h: 4 },
// Cost section
{ i: "llm-title-cost", x: 0, y: 4, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "llm-cost-time", x: 0, y: 6, w: 6, h: 13 },
{ i: "llm-cost-model", x: 6, y: 6, w: 6, h: 13 },
// Usage section
{ i: "llm-title-usage", x: 0, y: 19, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "llm-tokens-time", x: 0, y: 21, w: 6, h: 13 },
{ i: "llm-calls-model", x: 6, y: 21, w: 6, h: 13 },
// Performance section
{ i: "llm-title-perf", x: 0, y: 34, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "llm-ttfc-time", x: 0, y: 36, w: 6, h: 13 },
{ i: "llm-tps-model", x: 6, y: 36, w: 6, h: 13 },
{ i: "llm-latency-pct", x: 0, y: 49, w: 6, h: 13 },
{ i: "llm-latency-time", x: 6, y: 49, w: 6, h: 13 },
// Behavior section
{ i: "llm-title-behavior", x: 0, y: 62, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "llm-finish-reasons", x: 0, y: 64, w: 6, h: 13 },
{ i: "llm-top-runs", x: 6, y: 64, w: 6, h: 13 },
// Attribution section
{ i: "llm-title-attribution", x: 0, y: 77, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "llm-cost-task", x: 0, y: 79, w: 6, h: 13 },
{ i: "llm-cost-provider", x: 6, y: 79, w: 6, h: 13 },
{ i: "llm-cost-prompt", x: 0, y: 92, w: 6, h: 13 },
{ i: "llm-cost-user", x: 6, y: 92, w: 6, h: 13 },
// Efficiency section
{ i: "llm-title-efficiency", x: 0, y: 105, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "llm-cost-operation", x: 0, y: 107, w: 12, h: 13 },
// Caching section
{ i: "llm-title-caching", x: 0, y: 120, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "llm-cache-hit", x: 0, y: 122, w: 6, h: 13 },
{ i: "llm-cache-tokens", x: 6, y: 122, w: 6, h: 13 },
{ i: "llm-cache-savings", x: 0, y: 135, w: 6, h: 13 },
{ i: "llm-cache-by-model", x: 6, y: 135, w: 6, h: 13 },
],
widgets: {
"llm-cost": {
title: "Total LLM cost",
query: "SELECT\r\n SUM(total_cost) AS total_cost\r\nFROM\r\n llm_metrics",
display: {
type: "bignumber",
column: "total_cost",
aggregation: "sum",
abbreviate: true,
},
},
"llm-calls": {
title: "Total calls",
query: "SELECT\r\n count() AS total_calls\r\nFROM\r\n llm_metrics",
display: {
type: "bignumber",
column: "total_calls",
aggregation: "sum",
abbreviate: false,
},
},
"llm-ttfc": {
title: "Avg TTFC",
query:
"SELECT\r\n round(avg(ms_to_first_chunk), 1) AS avg_ttfc\r\nFROM\r\n llm_metrics\r\nWHERE ms_to_first_chunk > 0",
display: {
type: "bignumber",
column: "avg_ttfc",
aggregation: "avg",
abbreviate: false,
suffix: "ms",
},
},
"llm-tps": {
title: "Avg tokens/sec",
query:
"SELECT\r\n round(avg(tokens_per_second), 1) AS avg_tps\r\nFROM\r\n llm_metrics\r\nWHERE tokens_per_second > 0",
display: {
type: "bignumber",
column: "avg_tps",
aggregation: "avg",
abbreviate: false,
suffix: "/s",
},
},
"llm-title-cost": { title: "Cost", query: "", display: { type: "title" } },
"llm-cost-time": {
title: "Cost over time",
query:
"SELECT\r\n timeBucket(),\r\n SUM(total_cost) AS total_cost\r\nFROM\r\n llm_metrics\r\nGROUP BY\r\n timeBucket\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["total_cost"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-cost-model": {
title: "Cost by model",
query:
"SELECT\r\n timeBucket(),\r\n response_model,\r\n SUM(total_cost) AS total_cost\r\nFROM\r\n llm_metrics\r\nGROUP BY\r\n timeBucket,\r\n response_model\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["total_cost"],
groupByColumn: "response_model",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-title-usage": { title: "Usage", query: "", display: { type: "title" } },
"llm-tokens-time": {
title: "Tokens over time",
query:
"SELECT\r\n timeBucket(),\r\n SUM(input_tokens) AS input_tokens,\r\n SUM(output_tokens) AS output_tokens\r\nFROM\r\n llm_metrics\r\nGROUP BY\r\n timeBucket\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["input_tokens", "output_tokens"],
groupByColumn: null,
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-calls-model": {
title: "Calls by model",
query:
"SELECT\r\n response_model,\r\n count() AS calls,\r\n SUM(total_tokens) AS tokens,\r\n SUM(total_cost) AS cost\r\nFROM\r\n llm_metrics\r\nGROUP BY\r\n response_model\r\nORDER BY\r\n cost DESC",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"llm-title-perf": { title: "Performance", query: "", display: { type: "title" } },
"llm-ttfc-time": {
title: "TTFC over time",
query:
"SELECT\r\n timeBucket(),\r\n round(avg(ms_to_first_chunk), 1) AS avg_ttfc\r\nFROM\r\n llm_metrics\r\nWHERE ms_to_first_chunk > 0\r\nGROUP BY\r\n timeBucket\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["avg_ttfc"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
},
},
"llm-tps-model": {
title: "Tokens/sec by model",
query:
"SELECT\r\n timeBucket(),\r\n response_model,\r\n round(avg(tokens_per_second), 1) AS avg_tps\r\nFROM\r\n llm_metrics\r\nWHERE tokens_per_second > 0\r\nGROUP BY\r\n timeBucket,\r\n response_model\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["avg_tps"],
groupByColumn: "response_model",
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
},
},
"llm-latency-pct": {
title: "Latency percentiles by model",
query:
"SELECT\r\n response_model,\r\n round(quantile(0.5)(ms_to_first_chunk), 1) AS p50,\r\n round(quantile(0.9)(ms_to_first_chunk), 1) AS p90,\r\n round(quantile(0.95)(ms_to_first_chunk), 1) AS p95,\r\n round(quantile(0.99)(ms_to_first_chunk), 1) AS p99,\r\n count() AS calls\r\nFROM\r\n llm_metrics\r\nWHERE ms_to_first_chunk > 0\r\nGROUP BY\r\n response_model\r\nORDER BY\r\n p50 DESC",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"llm-latency-time": {
title: "Latency percentiles over time",
query:
"SELECT\r\n timeBucket(),\r\n round(quantile(0.5)(ms_to_first_chunk), 1) AS p50,\r\n round(quantile(0.95)(ms_to_first_chunk), 1) AS p95\r\nFROM\r\n llm_metrics\r\nWHERE ms_to_first_chunk > 0\r\nGROUP BY\r\n timeBucket\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["p50", "p95"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
seriesColors: { p95: "#f43f5e" },
},
},
"llm-title-behavior": { title: "Behavior", query: "", display: { type: "title" } },
"llm-finish-reasons": {
title: "Finish reasons over time",
query:
"SELECT\r\n timeBucket(),\r\n finish_reason,\r\n count() AS calls\r\nFROM\r\n llm_metrics\r\nWHERE finish_reason != ''\r\nGROUP BY\r\n timeBucket,\r\n finish_reason\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["calls"],
groupByColumn: "finish_reason",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-top-runs": {
title: "Most expensive runs",
query:
"SELECT\r\n run_id,\r\n task_identifier,\r\n SUM(total_cost) AS llm_cost,\r\n SUM(total_tokens) AS tokens\r\nFROM\r\n llm_metrics\r\nGROUP BY\r\n run_id,\r\n task_identifier\r\nORDER BY\r\n llm_cost DESC\r\nLIMIT 50",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"llm-title-attribution": { title: "Attribution", query: "", display: { type: "title" } },
"llm-cost-task": {
title: "Cost by task",
query:
"SELECT\r\n task_identifier,\r\n SUM(total_cost) AS cost,\r\n SUM(total_tokens) AS tokens,\r\n count() AS calls\r\nFROM\r\n llm_metrics\r\nGROUP BY\r\n task_identifier\r\nORDER BY\r\n cost DESC",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"llm-cost-provider": {
title: "Cost by provider",
query:
"SELECT\r\n timeBucket(),\r\n gen_ai_system,\r\n SUM(total_cost) AS total_cost\r\nFROM\r\n llm_metrics\r\nGROUP BY\r\n timeBucket,\r\n gen_ai_system\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["total_cost"],
groupByColumn: "gen_ai_system",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-cost-prompt": {
title: "Cost by prompt",
query:
"SELECT\r\n prompt_slug,\r\n SUM(total_cost) AS cost,\r\n SUM(total_tokens) AS tokens,\r\n count() AS calls\r\nFROM\r\n llm_metrics\r\nWHERE prompt_slug != ''\r\nGROUP BY\r\n prompt_slug\r\nORDER BY\r\n cost DESC",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"llm-cost-user": {
title: "Cost by user",
query:
"SELECT\r\n metadata['userId'] AS user_id,\r\n SUM(total_cost) AS cost,\r\n SUM(total_tokens) AS tokens,\r\n count() AS calls\r\nFROM\r\n llm_metrics\r\nWHERE metadata['userId'] != ''\r\nGROUP BY\r\n user_id\r\nORDER BY\r\n cost DESC\r\nLIMIT 20",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"llm-title-efficiency": { title: "Efficiency", query: "", display: { type: "title" } },
"llm-cost-operation": {
title: "Cost by operation type",
query:
"SELECT\r\n timeBucket(),\r\n operation_id,\r\n SUM(total_cost) AS total_cost\r\nFROM\r\n llm_metrics\r\nWHERE operation_id != ''\r\nGROUP BY\r\n timeBucket,\r\n operation_id\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["total_cost"],
groupByColumn: "operation_id",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-title-caching": { title: "Caching", query: "", display: { type: "title" } },
"llm-cache-hit": {
title: "Cache hit rate over time",
query:
"SELECT timeBucket(), round(ifNull(sum(cached_read_tokens) * 100.0 / nullIf(sum(input_tokens), 0), 0), 1) AS cache_hit_pct FROM llm_metrics GROUP BY timeBucket ORDER BY timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["cache_hit_pct"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
},
},
"llm-cache-tokens": {
title: "Cached tokens over time",
query:
"SELECT timeBucket(), sum(cached_read_tokens) AS cache_reads, sum(cache_creation_tokens) AS cache_writes FROM llm_metrics GROUP BY timeBucket ORDER BY timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["cache_reads", "cache_writes"],
groupByColumn: null,
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-cache-savings": {
title: "Cache savings over time",
query:
"SELECT timeBucket(), round(ifNull(sum(cached_read_tokens) * (sum(input_cost) / nullIf(sum(input_tokens) - sum(cached_read_tokens) - sum(cache_creation_tokens), 0)) - sum(cached_read_cost), 0), 4) AS cache_savings FROM llm_metrics WHERE cached_read_tokens > 0 GROUP BY timeBucket ORDER BY timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["cache_savings"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
"llm-cache-by-model": {
title: "Cache hit rate by model",
query:
"SELECT response_model, round(ifNull(sum(cached_read_tokens) * 100.0 / nullIf(sum(input_tokens), 0), 0), 1) AS cache_hit_pct, sum(cached_read_tokens) AS cached_tokens FROM llm_metrics GROUP BY response_model ORDER BY cached_tokens DESC LIMIT 20",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
},
},
};
const builtInDashboards: BuiltInDashboard[] = [overviewDashboard, llmDashboard];
export function builtInDashboardList(): BuiltInDashboard[] {
return builtInDashboards;
}
export function builtInDashboard(key: string): BuiltInDashboard {
const dashboard = builtInDashboards.find((d) => d.key === key);
if (!dashboard) {
throw new Error(`No built-in dashboard "${key}"`);
}
return dashboard;
}
@@ -0,0 +1,62 @@
import { getUsername } from "~/utils/username";
import { BasePresenter } from "./basePresenter.server";
type BulkActionListOptions = {
environmentId: string;
page?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type BulkActionListItem = Awaited<
ReturnType<BulkActionListPresenter["call"]>
>["bulkActions"][number];
export class BulkActionListPresenter extends BasePresenter {
public async call({ environmentId, page }: BulkActionListOptions) {
const totalCount = await this._replica.bulkActionGroup.count({
where: {
environmentId,
},
});
const bulkActions = await this._replica.bulkActionGroup.findMany({
select: {
friendlyId: true,
name: true,
status: true,
type: true,
createdAt: true,
completedAt: true,
totalCount: true,
user: {
select: {
name: true,
displayName: true,
avatarUrl: true,
},
},
},
where: {
environmentId,
},
orderBy: {
createdAt: "desc",
},
skip: ((page ?? 1) - 1) * DEFAULT_PAGE_SIZE,
take: DEFAULT_PAGE_SIZE,
});
return {
currentPage: page ?? 1,
totalPages: Math.ceil(totalCount / DEFAULT_PAGE_SIZE),
totalCount: totalCount,
bulkActions: bulkActions.map((bulkAction) => ({
...bulkAction,
user: bulkAction.user
? { name: getUsername(bulkAction.user), avatarUrl: bulkAction.user.avatarUrl }
: undefined,
})),
};
}
}
@@ -0,0 +1,72 @@
import { type BulkActionMode } from "~/components/BulkActionFilterSummary";
import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { getUsername } from "~/utils/username";
import { BasePresenter } from "./basePresenter.server";
type BulkActionOptions = {
environmentId: string;
bulkActionId: string;
};
export class BulkActionPresenter extends BasePresenter {
public async call({ environmentId, bulkActionId }: BulkActionOptions) {
const bulkAction = await this._replica.bulkActionGroup.findFirst({
select: {
friendlyId: true,
name: true,
status: true,
type: true,
createdAt: true,
completedAt: true,
totalCount: true,
successCount: true,
failureCount: true,
user: {
select: {
name: true,
displayName: true,
avatarUrl: true,
},
},
params: true,
project: {
select: {
id: true,
organizationId: true,
},
},
},
where: {
environmentId,
friendlyId: bulkActionId,
},
});
if (!bulkAction) {
throw new Error("Bulk action not found");
}
//parse filters
const filtersParsed = TaskRunListSearchFilters.safeParse(
bulkAction.params && typeof bulkAction.params === "object" ? bulkAction.params : {}
);
let mode: BulkActionMode = "filter";
if (
filtersParsed.success &&
Object.keys(filtersParsed.data).length === 1 &&
filtersParsed.data.runId?.length
) {
mode = "selected";
}
return {
...bulkAction,
user: bulkAction.user
? { name: getUsername(bulkAction.user), avatarUrl: bulkAction.user.avatarUrl }
: undefined,
filters: filtersParsed.data ?? {},
mode,
};
}
}
@@ -0,0 +1,50 @@
import { type PrismaClient } from "@trigger.dev/database";
import { CreateBulkActionSearchParams } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { getRunFiltersFromRequest } from "../RunFilters.server";
import { BasePresenter } from "./basePresenter.server";
type CreateBulkActionOptions = {
organizationId: string;
projectId: string;
environmentId: string;
request: Request;
};
export class CreateBulkActionPresenter extends BasePresenter {
public async call({
organizationId,
projectId,
environmentId,
request,
}: CreateBulkActionOptions) {
const filters = await getRunFiltersFromRequest(request);
const { mode, action } = CreateBulkActionSearchParams.parse(
Object.fromEntries(new URL(request.url).searchParams)
);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const runsRepository = new RunsRepository({
clickhouse,
prisma: this._replica as PrismaClient,
});
const count = await runsRepository.countRuns({
organizationId,
projectId,
environmentId,
...filters,
});
return {
filters,
mode,
action,
count,
};
}
}
@@ -0,0 +1,333 @@
import {
Prisma,
type WorkerDeploymentStatus,
type WorkerInstanceGroupType,
} from "@trigger.dev/database";
import { sqlDatabaseSchema, type PrismaClient, prisma } from "~/db.server";
import { type Organization } from "~/models/organization.server";
import { type Project } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { processGitMetadata } from "./BranchesPresenter.server";
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
import {
VercelProjectIntegrationDataSchema,
buildVercelDeploymentUrl,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
const pageSize = 20;
export type DeploymentList = Awaited<ReturnType<DeploymentListPresenter["call"]>>;
export type DeploymentListItem = DeploymentList["deployments"][0];
export class DeploymentListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
environmentSlug,
page = 1,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
environmentSlug: string;
page?: number;
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
connectedGithubRepository: {
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
repository: {
select: {
htmlUrl: true,
fullName: true,
},
},
},
},
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
});
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
if (!environment) {
throw new Error(`Environment not found`);
}
const totalCount = await this.#prismaClient.workerDeployment.count({
where: {
projectId: project.id,
environmentId: environment.id,
},
});
const labeledDeployments = await this.#prismaClient.workerDeploymentPromotion.findMany({
where: {
environmentId: environment.id,
},
select: {
deploymentId: true,
label: true,
},
});
// Check for Vercel integration before the main query so we can conditionally LEFT JOIN
let hasVercelIntegration = false;
let vercelTeamSlug: string | undefined;
let vercelProjectName: string | undefined;
const vercelProjectIntegration =
await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: project.id,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
select: {
integrationData: true,
},
});
if (vercelProjectIntegration) {
const parsed = VercelProjectIntegrationDataSchema.safeParse(
vercelProjectIntegration.integrationData
);
if (parsed.success && parsed.data.vercelTeamSlug) {
hasVercelIntegration = true;
vercelTeamSlug = parsed.data.vercelTeamSlug;
vercelProjectName = parsed.data.vercelProjectName;
}
}
const vercelSelect = hasVercelIntegration
? Prisma.sql`, id_dep."integrationDeploymentId"`
: Prisma.sql``;
const vercelJoin = hasVercelIntegration
? Prisma.sql`LEFT JOIN LATERAL (
SELECT id_inner."integrationDeploymentId"
FROM ${sqlDatabaseSchema}."IntegrationDeployment" as id_inner
WHERE id_inner."deploymentId" = wd."id" AND id_inner."integrationName" = 'vercel'
ORDER BY id_inner."createdAt" DESC
LIMIT 1
) id_dep ON true`
: Prisma.sql``;
const deployments = await this.#prismaClient.$queryRaw<
{
id: string;
shortCode: string;
version: string;
runtime: string | null;
runtimeVersion: string | null;
status: WorkerDeploymentStatus;
environmentId: string;
builtAt: Date | null;
deployedAt: Date | null;
tasksCount: bigint | null;
userId: string | null;
userName: string | null;
userDisplayName: string | null;
userAvatarUrl: string | null;
type: WorkerInstanceGroupType;
git: Prisma.JsonValue | null;
integrationDeploymentId: string | null;
}[]
>`
SELECT
wd."id",
wd."shortCode",
wd."version",
wd."runtime",
wd."runtimeVersion",
(SELECT COUNT(*) FROM ${sqlDatabaseSchema}."BackgroundWorkerTask" WHERE "BackgroundWorkerTask"."workerId" = wd."workerId") AS "tasksCount",
wd."environmentId",
wd."status",
u."id" AS "userId",
u."name" AS "userName",
u."displayName" AS "userDisplayName",
u."avatarUrl" AS "userAvatarUrl",
wd."builtAt",
wd."deployedAt",
wd."type",
wd."git"
${vercelSelect}
FROM
${sqlDatabaseSchema}."WorkerDeployment" as wd
LEFT JOIN
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
${vercelJoin}
WHERE
wd."projectId" = ${project.id}
AND wd."environmentId" = ${environment.id}
ORDER BY
string_to_array(wd."version", '.')::int[] DESC
LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
const { connectedGithubRepository } = project;
const branchTrackingOrError =
connectedGithubRepository &&
BranchTrackingConfigSchema.safeParse(connectedGithubRepository.branchTracking);
const environmentGitHubBranch =
branchTrackingOrError && branchTrackingOrError.success
? getTrackedBranchForEnvironment(
branchTrackingOrError.data,
connectedGithubRepository.previewDeploymentsEnabled,
{
type: environment.type,
branchName: environment.branchName ?? undefined,
}
)
: undefined;
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
hasVercelIntegration,
connectedGithubRepository: project.connectedGithubRepository ?? undefined,
environmentGitHubBranch,
deployments: deployments.map((deployment, index) => {
const label = labeledDeployments.find(
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
);
let vercelDeploymentUrl: string | null = null;
if (
hasVercelIntegration &&
deployment.integrationDeploymentId &&
vercelTeamSlug &&
vercelProjectName
) {
vercelDeploymentUrl = buildVercelDeploymentUrl(
vercelTeamSlug,
vercelProjectName,
deployment.integrationDeploymentId
);
}
return {
id: deployment.id,
shortCode: deployment.shortCode,
version: deployment.version,
runtime: deployment.runtime,
runtimeVersion: deployment.runtimeVersion,
status: deployment.status,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
tasksCount: deployment.tasksCount ? Number(deployment.tasksCount) : null,
label: label?.label,
isBuilt: !!deployment.builtAt,
isCurrent: label?.label === "current",
isDeployed: deployment.status === "DEPLOYED",
isLatest: page === 1 && index === 0,
type: deployment.type,
environment: {
id: environment.id,
type: environment.type,
slug: environment.slug,
},
deployedBy: deployment.userId
? {
id: deployment.userId,
name: deployment.userName,
displayName: deployment.userDisplayName,
avatarUrl: deployment.userAvatarUrl,
}
: undefined,
git: processGitMetadata(deployment.git),
vercelDeploymentUrl,
};
}),
};
}
public async findPageForVersion({
userId,
projectSlug,
organizationSlug,
environmentSlug,
version,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
environmentSlug: string;
version: string;
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
});
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
if (!environment) {
throw new Error(`Environment not found`);
}
// Find how many deployments have been made since this version
const deploymentsSinceVersion = await this.#prismaClient.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM ${sqlDatabaseSchema}."WorkerDeployment"
WHERE "projectId" = ${project.id}
AND "environmentId" = ${environment.id}
AND string_to_array(version, '.')::int[] > string_to_array(${version}, '.')::int[]
`;
const count = Number(deploymentsSinceVersion[0].count);
return Math.floor(count / pageSize) + 1;
}
}
@@ -0,0 +1,330 @@
import {
BuildServerMetadata,
DeploymentErrorData,
ExternalBuildData,
prepareDeploymentError,
} from "@trigger.dev/core/v3";
import { type RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
import { type PrismaClient, prisma } from "~/db.server";
import { type Organization } from "~/models/organization.server";
import { type Project } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
import { processGitMetadata } from "./BranchesPresenter.server";
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { S2 } from "@s2-dev/streamstore";
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
import { tryCatch } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
const s2TokenRedis = createRedisClient("s2-token-cache", {
host: env.CACHE_REDIS_HOST,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
});
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
export type ErrorData = {
name: string;
message: string;
stack?: string;
stderr?: string;
};
export class DeploymentPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
environmentSlug,
deploymentShortCode,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
environmentSlug: RuntimeEnvironment["slug"];
deploymentShortCode: WorkerDeployment["shortCode"];
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
organizationId: true,
externalRef: true,
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
});
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
if (!environment) {
throw new Error(`Environment not found`);
}
const deployment = await this.#prismaClient.workerDeployment.findFirstOrThrow({
where: {
projectId: project.id,
shortCode: deploymentShortCode,
environmentId: environment.id,
},
select: {
id: true,
shortCode: true,
version: true,
runtime: true,
runtimeVersion: true,
errorData: true,
imageReference: true,
imagePlatform: true,
externalBuildData: true,
projectId: true,
type: true,
environment: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
status: true,
builtAt: true,
deployedAt: true,
createdAt: true,
startedAt: true,
installedAt: true,
canceledAt: true,
canceledReason: true,
git: true,
promotions: {
select: {
label: true,
},
},
worker: {
select: {
tasks: {
select: {
slug: true,
filePath: true,
},
orderBy: {
slug: "asc",
},
},
sdkVersion: true,
cliVersion: true,
},
},
triggeredBy: {
select: {
id: true,
name: true,
displayName: true,
avatarUrl: true,
},
},
buildServerMetadata: true,
triggeredVia: true,
},
});
const gitMetadata = processGitMetadata(deployment.git);
// Look up Vercel integration data to construct a deployment URL
let vercelDeploymentUrl: string | undefined;
const vercelProjectIntegration =
await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: project.id,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
select: {
integrationData: true,
},
});
if (vercelProjectIntegration) {
const parsed = VercelProjectIntegrationDataSchema.safeParse(
vercelProjectIntegration.integrationData
);
if (parsed.success && parsed.data.vercelTeamSlug) {
const integrationDeployment = await this.#prismaClient.integrationDeployment.findFirst({
where: {
deploymentId: deployment.id,
integrationName: "vercel",
},
select: {
integrationDeploymentId: true,
},
orderBy: {
createdAt: "desc",
},
});
if (integrationDeployment) {
const vercelId = integrationDeployment.integrationDeploymentId;
vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
}
}
}
const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
const buildServerMetadata = deployment.buildServerMetadata
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
: undefined;
let eventStream = undefined;
if (
env.S2_ENABLED === "1" &&
(buildServerMetadata ||
gitMetadata?.source === "trigger_github_app" ||
env.S2_DEPLOYMENT_STREAMS_LOCAL === "1")
) {
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
if (error) {
logger.error("Failed getting S2 access token", { error });
} else {
eventStream = {
s2: {
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
accessToken,
},
};
}
}
return {
eventStream,
deployment: {
id: deployment.id,
shortCode: deployment.shortCode,
version: deployment.version,
status: deployment.status,
createdAt: deployment.createdAt,
startedAt: deployment.startedAt,
installedAt: deployment.installedAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
canceledAt: deployment.canceledAt,
canceledReason: deployment.canceledReason,
tasks: deployment.worker?.tasks,
label: deployment.promotions?.[0]?.label,
environment: {
id: deployment.environment.id,
type: deployment.environment.type,
slug: deployment.environment.slug,
userId: deployment.environment.orgMember?.user.id,
userName: getUsername(deployment.environment.orgMember?.user),
},
deployedBy: deployment.triggeredBy,
sdkVersion: deployment.worker?.sdkVersion,
cliVersion: deployment.worker?.cliVersion,
runtime: deployment.runtime,
runtimeVersion: deployment.runtimeVersion,
imageReference: deployment.imageReference,
imagePlatform: deployment.imagePlatform,
externalBuildData:
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
projectId: deployment.projectId,
organizationId: project.organizationId,
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
isBuilt: !!deployment.builtAt,
type: deployment.type,
git: gitMetadata,
triggeredVia: deployment.triggeredVia,
vercelDeploymentUrl,
},
};
}
private async getS2AccessToken(projectRef: string): Promise<string> {
if (env.S2_ENABLED !== "1" || !s2) {
throw new Error("Failed getting S2 access token: S2 is not enabled");
}
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
const cachedToken = await s2TokenRedis.get(redisKey);
if (cachedToken) {
return cachedToken;
}
const { accessToken } = await s2.accessTokens.issue({
id: `${projectRef}-${new Date().getTime()}`,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
scope: {
ops: ["read"],
basins: {
exact: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
},
streams: {
prefix: `projects/${projectRef}/deployments/`,
},
},
});
await s2TokenRedis.setex(
redisKey,
59 * 60, // slightly shorter than the token validity period
accessToken
);
return accessToken;
}
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
if (!errorData) {
return;
}
const deploymentError = DeploymentErrorData.safeParse(errorData);
if (!deploymentError.success) {
return;
}
return prepareDeploymentError(deploymentError.data);
}
}
@@ -0,0 +1,98 @@
import Redis, { type RedisOptions } from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import { env } from "~/env.server";
import { subDays } from "date-fns";
const DEV_RECENT_DEBOUNCE_SEC = 60;
const DEV_RECENT_TTL = 7 * 24 * 60 * 60; // 7 days
const RECENCY_DAYS = 3;
export class DevPresence {
private redis: Redis;
constructor(options: RedisOptions) {
this.redis = new Redis({ reconnectOnError: defaultReconnectOnError, ...options });
}
async isConnected(environmentId: string) {
const presenceKey = this.getPresenceKey(environmentId);
const presenceValue = await this.redis.get(presenceKey);
return !!presenceValue;
}
async isConnectedMany(environmentIds: string[]): Promise<Map<string, boolean>> {
if (environmentIds.length === 0) return new Map();
const keys = environmentIds.map((id) => this.getPresenceKey(id));
const values = await this.redis.mget(keys);
return new Map(environmentIds.map((id, i) => [id, !!values[i]]));
}
async setConnected({
userId,
projectId,
environmentId,
ttl,
}: {
userId: string;
projectId: string;
environmentId: string;
ttl: number;
}) {
const presenceKey = this.getPresenceKey(environmentId);
await this.redis.setex(presenceKey, ttl, new Date().toISOString());
const touchKey = this.getTouchKey(environmentId);
const acquired = await this.redis.set(touchKey, "1", "EX", DEV_RECENT_DEBOUNCE_SEC, "NX");
if (acquired !== null) {
const recentKey = this.getRecentKey(userId, projectId);
const now = new Date();
const threeDaysAgo = subDays(now, RECENCY_DAYS);
await this.redis
.multi()
.zadd(recentKey, now.getTime(), environmentId)
.zremrangebyscore(recentKey, 0, threeDaysAgo.getTime())
.zremrangebyrank(recentKey, 0, -51)
.expire(recentKey, DEV_RECENT_TTL)
.exec();
}
}
async getRecentBranchIds(userId: string, projectId: string) {
const recentKey = this.getRecentKey(userId, projectId);
const threeDaysAgo = subDays(Date.now(), RECENCY_DAYS);
const raw = await this.redis.zrevrangebyscore(
recentKey,
"+inf",
threeDaysAgo.getTime(),
"WITHSCORES"
);
const branches = new Map<string, Date>();
for (let i = 0; i < raw.length; i += 2) {
branches.set(raw[i], new Date(Number(raw[i + 1])));
}
return branches;
}
private getPresenceKey(environmentId: string) {
return `dev-presence:connection:${environmentId}`;
}
private getRecentKey(userId: string, projectId: string) {
return `dev-recent:${userId}:${projectId}`;
}
private getTouchKey(environmentId: string) {
return `dev-recent-touch:${environmentId}`;
}
}
export const devPresence = new DevPresence({
port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined,
host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined,
username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined,
password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined,
enableAutoPipelining: true,
...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
});
@@ -0,0 +1,160 @@
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import { type PrismaClient, prisma } from "~/db.server";
import { displayableEnvironment, findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { filterOrphanedEnvironments } from "~/utils/environmentSort";
import { getTimezones } from "~/utils/timezones.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
type EditScheduleOptions = {
userId: string;
projectSlug: string;
environmentSlug: string;
friendlyId?: string;
};
export type EditableScheduleElements = Awaited<ReturnType<EditSchedulePresenter["call"]>>;
type Environment = {
id: string;
type: RuntimeEnvironmentType;
userName?: string;
};
export class EditSchedulePresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId, projectSlug, environmentSlug, friendlyId }: EditScheduleOptions) {
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
branchName: true,
parentEnvironmentId: true,
},
},
},
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
if (!environment) {
throw new ServiceValidationError("No matching environment for project", 404);
}
//get the latest BackgroundWorker
const latestWorker = await findCurrentWorkerFromEnvironment(environment, this.#prismaClient);
//get all possible scheduled tasks
const possibleTasks = latestWorker
? await this.#prismaClient.backgroundWorkerTask.findMany({
where: {
workerId: latestWorker.id,
projectId: project.id,
runtimeEnvironmentId: environment.id,
triggerSource: "SCHEDULED",
},
})
: [];
const possibleEnvironments = filterOrphanedEnvironments(project.environments)
// Exclude the branchable PREVIEW parent (it has no parent of its own);
// only actual preview branches are schedulable.
.filter(
(environment) =>
!(environment.type === "PREVIEW" && environment.parentEnvironmentId === null)
)
.map((environment) => {
return {
...displayableEnvironment(environment, userId),
branchName: environment.branchName ?? undefined,
};
});
return {
possibleTasks: possibleTasks.map((task) => task.slug).sort(),
possibleEnvironments,
possibleTimezones: getTimezones(),
schedule: await this.#getExistingSchedule(friendlyId, possibleEnvironments),
};
}
async #getExistingSchedule(scheduleId: string | undefined, possibleEnvironments: Environment[]) {
if (!scheduleId) {
return undefined;
}
const schedule = await this.#prismaClient.taskSchedule.findFirst({
select: {
id: true,
type: true,
friendlyId: true,
generatorExpression: true,
externalId: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
timezone: true,
taskIdentifier: true,
instances: {
select: {
environmentId: true,
},
},
active: true,
},
where: {
friendlyId: scheduleId,
},
});
if (!schedule) {
return undefined;
}
return {
...schedule,
cron: schedule.generatorExpression,
environments: schedule.instances.flatMap((instance) => {
const environment = possibleEnvironments.find((env) => env.id === instance.environmentId);
if (!environment) {
logger.error(
`EditSchedulePresenter: environment with id ${instance.environmentId} not found`
);
return [];
}
return [environment];
}),
};
}
}
@@ -0,0 +1,58 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { marqs } from "~/v3/marqs/index.server";
import { engine } from "~/v3/runEngine.server";
import { getQueueSizeLimit } from "~/v3/utils/queueLimits.server";
import { BasePresenter } from "./basePresenter.server";
export type Environment = {
running: number;
queued: number;
concurrencyLimit: number;
burstFactor: number;
runsEnabled: boolean;
queueSizeLimit: number | null;
};
export class EnvironmentQueuePresenter extends BasePresenter {
async call(environment: AuthenticatedEnvironment): Promise<Environment> {
const [engineV1Executing, engineV2Executing, engineV1Queued, engineV2Queued] =
await Promise.all([
marqs.currentConcurrencyOfEnvironment(environment),
engine.concurrencyOfEnvQueue(environment),
marqs.lengthOfEnvQueue(environment),
engine.lengthOfEnvQueue(environment),
]);
const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0);
const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0);
const organization = await this._replica.organization.findFirst({
where: {
id: environment.organizationId,
},
select: {
runsEnabled: true,
maximumDevQueueSize: true,
maximumDeployedQueueSize: true,
},
});
if (!organization) {
throw new Error("Organization not found");
}
const queueSizeLimit = getQueueSizeLimit(environment.type, organization);
return {
running,
queued,
concurrencyLimit: environment.maximumConcurrencyLimit,
burstFactor:
typeof environment.concurrencyLimitBurstFactor === "number"
? environment.concurrencyLimitBurstFactor
: environment.concurrencyLimitBurstFactor.toNumber(),
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
queueSizeLimit,
};
}
}
@@ -0,0 +1,216 @@
import type { PrismaClient, PrismaReplicaClient } from "~/db.server";
import { $replica, prisma } from "~/db.server";
import type { Project } from "~/models/project.server";
import type { User } from "~/models/user.server";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository";
import type { SyncEnvVarsMapping, EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
import { loadEnvironmentVariablesEnvironments } from "./environmentVariablesEnvironments.server";
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];
export class EnvironmentVariablesPresenter {
#prismaClient: PrismaClient;
#replicaClient: PrismaReplicaClient;
constructor(prismaClient: PrismaClient = prisma, replicaClient: PrismaReplicaClient = $replica) {
this.#prismaClient = prismaClient;
this.#replicaClient = replicaClient;
}
public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) {
const project = await this.#replicaClient.project.findFirst({
select: {
id: true,
},
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
throw new Error("Project not found");
}
const { environments: sortedEnvironments, hasStaging } =
await loadEnvironmentVariablesEnvironments(
this.#replicaClient,
{ userId, projectId: project.id },
{ skipProjectAccessCheck: true }
);
// Only load values for the environments we display. Projects can accumulate
// values in archived branch environments, which would otherwise all be loaded here.
const environmentIds = sortedEnvironments.map((env) => env.id);
const environmentVariables = await this.#replicaClient.environmentVariable.findMany({
select: {
id: true,
key: true,
values: {
select: {
id: true,
environmentId: true,
version: true,
lastUpdatedBy: true,
updatedAt: true,
valueReference: {
select: {
key: true,
},
},
isSecret: true,
},
where: {
environmentId: {
in: environmentIds,
},
},
},
},
where: {
projectId: project.id,
},
});
const userIds = new Set(
environmentVariables
.flatMap((envVar) => envVar.values)
.map((value) => value.lastUpdatedBy)
.filter(
(lastUpdatedBy): lastUpdatedBy is { type: "user"; userId: string } =>
lastUpdatedBy !== null &&
typeof lastUpdatedBy === "object" &&
"type" in lastUpdatedBy &&
lastUpdatedBy.type === "user" &&
"userId" in lastUpdatedBy &&
typeof lastUpdatedBy.userId === "string"
)
.map((lastUpdatedBy) => lastUpdatedBy.userId)
);
const users =
userIds.size > 0
? await this.#replicaClient.user.findMany({
where: {
id: {
in: Array.from(userIds),
},
},
select: {
id: true,
name: true,
displayName: true,
avatarUrl: true,
},
})
: [];
const usersRecord: Record<
string,
{ id: string; name: string | null; displayName: string | null; avatarUrl: string | null }
> = Object.fromEntries(users.map((u) => [u.id, u]));
const repository = new EnvironmentVariablesRepository(this.#prismaClient, this.#replicaClient);
const nonSecretItems: Array<{ environmentId: string; key: string }> = [];
for (const environmentVariable of environmentVariables) {
for (const env of sortedEnvironments) {
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
if (valueRecord && !valueRecord.isSecret) {
nonSecretItems.push({ environmentId: env.id, key: environmentVariable.key });
}
}
}
const variableValuesByEnvAndKey = await repository.getVariableValuesForKeys(
project.id,
nonSecretItems
);
// Get Vercel integration data if it exists
const vercelService = new VercelIntegrationService(this.#prismaClient);
const vercelIntegration = await vercelService.getVercelProjectIntegration(project.id);
let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {};
let vercelPullEnvVarsBeforeBuild: EnvSlug[] | null = null;
if (vercelIntegration) {
vercelSyncEnvVarsMapping = vercelIntegration.parsedIntegrationData.syncEnvVarsMapping;
vercelPullEnvVarsBeforeBuild =
vercelIntegration.parsedIntegrationData.config.pullEnvVarsBeforeBuild ?? null;
}
return {
environmentVariables: environmentVariables
.flatMap((environmentVariable) => {
return sortedEnvironments.flatMap((env) => {
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
const isSecret = valueRecord?.isSecret ?? false;
if (!valueRecord) {
return [];
}
const val = isSecret
? undefined
: variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`);
if (!isSecret && val === undefined) {
return [];
}
const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null;
const updatedByUser =
lastUpdatedBy?.type === "user"
? (() => {
const user = usersRecord[lastUpdatedBy.userId];
return user
? {
id: user.id,
name: user.displayName || user.name || "Unknown",
avatarUrl: user.avatarUrl,
}
: null;
})()
: null;
return [
{
id: environmentVariable.id,
key: environmentVariable.key,
environment: { type: env.type, id: env.id, branchName: env.branchName },
value: isSecret ? "" : val!,
isSecret,
version: valueRecord.version,
lastUpdatedBy,
updatedByUser,
updatedAt: valueRecord.updatedAt,
},
];
});
})
.sort((a, b) => a.key.localeCompare(b.key)),
environments: sortedEnvironments,
hasStaging,
// Vercel integration data
vercelIntegration: vercelIntegration
? {
enabled: true,
pullEnvVarsBeforeBuild: vercelPullEnvVarsBeforeBuild,
syncEnvVarsMapping: vercelSyncEnvVarsMapping,
}
: null,
};
}
}
@@ -0,0 +1,73 @@
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { BasePresenter } from "./basePresenter.server";
import { NewAlertChannelPresenter } from "./NewAlertChannelPresenter.server";
import { env } from "~/env.server";
export type ErrorAlertChannelData = Awaited<ReturnType<ErrorAlertChannelPresenter["call"]>>;
export class ErrorAlertChannelPresenter extends BasePresenter {
public async call(projectId: string, environmentType: RuntimeEnvironmentType) {
const channels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId,
alertTypes: { has: "ERROR_GROUP" },
environmentTypes: { has: environmentType },
},
orderBy: { createdAt: "asc" },
});
const emails: Array<{ id: string; email: string }> = [];
const webhooks: Array<{ id: string; url: string }> = [];
let slackChannel: { id: string; channelId: string; channelName: string } | null = null;
for (const channel of channels) {
switch (channel.type) {
case "EMAIL": {
const parsed = ProjectAlertEmailProperties.safeParse(channel.properties);
if (parsed.success) {
emails.push({ id: channel.id, email: parsed.data.email });
}
break;
}
case "SLACK": {
if (!channel.enabled) break;
const parsed = ProjectAlertSlackProperties.safeParse(channel.properties);
if (parsed.success) {
slackChannel = {
id: channel.id,
channelId: parsed.data.channelId,
channelName: parsed.data.channelName,
};
}
break;
}
case "WEBHOOK": {
const parsed = ProjectAlertWebhookProperties.safeParse(channel.properties);
if (parsed.success) {
webhooks.push({ id: channel.id, url: parsed.data.url });
}
break;
}
}
}
const slackPresenter = new NewAlertChannelPresenter(this._prisma, this._replica);
const slackResult = await slackPresenter.call(projectId);
const emailAlertsEnabled =
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
return {
emails,
webhooks,
slackChannel,
slack: slackResult.slack,
emailAlertsEnabled,
};
}
}
@@ -0,0 +1,439 @@
import { z } from "zod";
import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse";
import { TimeGranularity } from "~/utils/timeGranularity";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { type Direction, DirectionSchema } from "~/components/ListPagination";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
import {
NextRunListPresenter,
type NextRunList,
} from "~/presenters/v3/NextRunListPresenter.server";
import { sortVersionsDescending } from "~/utils/semver";
const errorGroupGranularity = new TimeGranularity([
{ max: "1h", granularity: "1m" },
{ max: "1d", granularity: "20m" },
{ max: "1w", granularity: "2h" },
{ max: "31d", granularity: "12h" },
{ max: "60d", granularity: "1w" },
{ max: "Infinity", granularity: "30d" },
]);
export type ErrorGroupOptions = {
userId?: string;
projectId: string;
fingerprint: string;
versions?: string[];
runsPageSize?: number;
period?: string;
from?: number;
to?: number;
cursor?: string;
direction?: Direction;
};
export const ErrorGroupOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
fingerprint: z.string(),
versions: z.array(z.string()).optional(),
runsPageSize: z.number().int().positive().max(1000).optional(),
period: z.string().optional(),
from: z.number().int().nonnegative().optional(),
to: z.number().int().nonnegative().optional(),
cursor: z.string().optional(),
direction: DirectionSchema.optional(),
});
const DEFAULT_RUNS_PAGE_SIZE = 25;
export type ErrorGroupDetail = Awaited<ReturnType<ErrorGroupPresenter["call"]>>;
function parseClickHouseDateTime(value: string): Date {
const asNum = Number(value);
if (!isNaN(asNum) && asNum > 1e12) {
return new Date(asNum);
}
return new Date(value.replace(" ", "T") + "Z");
}
export type ErrorGroupState = {
status: ErrorGroupStatus;
resolvedAt: Date | null;
resolvedInVersion: string | null;
resolvedBy: string | null;
ignoredAt: Date | null;
ignoredUntil: Date | null;
ignoredReason: string | null;
ignoredByUserId: string | null;
ignoredByUserDisplayName: string | null;
ignoredUntilOccurrenceRate: number | null;
ignoredUntilTotalOccurrences: number | null;
ignoredAtOccurrenceCount: number | null;
};
export type ErrorGroupSummary = {
fingerprint: string;
errorType: string;
errorMessage: string;
taskIdentifier: string;
count: number;
firstSeen: Date;
lastSeen: Date;
affectedVersions: string[];
state: ErrorGroupState;
};
export type ErrorGroupOccurrences = Awaited<ReturnType<ErrorGroupPresenter["getOccurrences"]>>;
export type ErrorGroupActivity = ErrorGroupOccurrences["data"];
export type ErrorGroupActivityVersions = ErrorGroupOccurrences["versions"];
export class ErrorGroupPresenter extends BasePresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly logsClickhouse: ClickHouse,
private readonly clickhouse: ClickHouse
) {
super(undefined, replica);
}
public async call(
organizationId: string,
environmentId: string,
{
userId,
projectId,
fingerprint,
versions,
runsPageSize = DEFAULT_RUNS_PAGE_SIZE,
period,
from,
to,
cursor,
direction,
}: ErrorGroupOptions
) {
const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId);
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
const time = timeFilterFromTo({
period,
from,
to,
defaultPeriod: "7d",
});
const summary = await this.getSummary(organizationId, projectId, environmentId, fingerprint);
const [affectedVersions, runList, stateRow] = await Promise.all([
this.getAffectedVersions(organizationId, projectId, environmentId, fingerprint),
this.getRunList(organizationId, environmentId, {
userId,
projectId,
fingerprint,
versions,
pageSize: runsPageSize,
from: time.from.getTime(),
to: time.to.getTime(),
cursor,
direction,
}),
this.getState(environmentId, summary?.taskIdentifier, fingerprint),
]);
if (summary) {
summary.affectedVersions = affectedVersions;
summary.state = stateRow ?? {
status: "UNRESOLVED",
resolvedAt: null,
resolvedInVersion: null,
resolvedBy: null,
ignoredAt: null,
ignoredUntil: null,
ignoredReason: null,
ignoredByUserId: null,
ignoredByUserDisplayName: null,
ignoredUntilOccurrenceRate: null,
ignoredUntilTotalOccurrences: null,
ignoredAtOccurrenceCount: null,
};
}
return {
errorGroup: summary,
runList,
filters: {
from: time.from,
to: time.to,
},
};
}
/**
* Returns bucketed occurrence counts for a single fingerprint over a time range,
* grouped by task_version for stacked charts.
*/
public async getOccurrences(
organizationId: string,
projectId: string,
environmentId: string,
fingerprint: string,
from: Date,
to: Date,
versions?: string[]
): Promise<{
data: Array<Record<string, number | Date>>;
versions: string[];
}> {
const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to);
const intervalExpr = msToClickHouseInterval(granularityMs);
const queryBuilder =
this.logsClickhouse.errors.createOccurrencesByVersionQueryBuilder(intervalExpr);
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint });
queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({fromTimeMs: Int64}))", {
fromTimeMs: from.getTime(),
});
queryBuilder.where("minute <= toStartOfMinute(fromUnixTimestamp64Milli({toTimeMs: Int64}))", {
toTimeMs: to.getTime(),
});
if (versions && versions.length > 0) {
queryBuilder.where("task_version IN {versions: Array(String)}", { versions });
}
queryBuilder.groupBy("error_fingerprint, task_version, bucket_epoch");
queryBuilder.orderBy("bucket_epoch ASC");
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
// Build time buckets covering the full range
const buckets: number[] = [];
const startEpoch = Math.floor(from.getTime() / granularityMs) * (granularityMs / 1000);
const endEpoch = Math.ceil(to.getTime() / 1000);
for (let epoch = startEpoch; epoch <= endEpoch; epoch += granularityMs / 1000) {
buckets.push(epoch);
}
// Collect distinct versions and index results by (epoch, version)
const versionSet = new Set<string>();
const byBucketVersion = new Map<string, number>();
for (const row of records ?? []) {
const version = row.task_version || "unknown";
versionSet.add(version);
const key = `${row.bucket_epoch}:${version}`;
byBucketVersion.set(key, (byBucketVersion.get(key) ?? 0) + row.count);
}
const sortedVersions = sortVersionsDescending([...versionSet]);
// Build the data for the graph
// For each time bucket, if a value exists for a version set the value (don't add zeros)
const data = buckets.map((epoch) => {
const point: Record<string, number | Date> = { date: new Date(epoch * 1000) };
for (const version of sortedVersions) {
const versionValue = byBucketVersion.get(`${epoch}:${version}`);
if (versionValue) {
point[version] = versionValue;
}
}
return point;
});
return { data, versions: sortedVersions };
}
private async getSummary(
organizationId: string,
projectId: string,
environmentId: string,
fingerprint: string
): Promise<ErrorGroupSummary | undefined> {
const queryBuilder = this.logsClickhouse.errors.listQueryBuilder();
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint });
queryBuilder.groupBy("error_fingerprint, task_identifier");
queryBuilder.limit(1);
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (!records || records.length === 0) {
return undefined;
}
const record = records[0];
return {
fingerprint: record.error_fingerprint,
errorType: record.error_type,
errorMessage: record.error_message,
taskIdentifier: record.task_identifier,
count: record.occurrence_count,
firstSeen: parseClickHouseDateTime(record.first_seen),
lastSeen: parseClickHouseDateTime(record.last_seen),
affectedVersions: [],
state: {
status: "UNRESOLVED" as const,
resolvedAt: null,
resolvedInVersion: null,
resolvedBy: null,
ignoredAt: null,
ignoredUntil: null,
ignoredReason: null,
ignoredByUserId: null,
ignoredByUserDisplayName: null,
ignoredUntilOccurrenceRate: null,
ignoredUntilTotalOccurrences: null,
ignoredAtOccurrenceCount: null,
},
};
}
/**
* Returns the most recent distinct task_version values for an error fingerprint,
* sorted by semantic version descending (newest first).
* Queries error_occurrences_v1 where task_version is part of the ORDER BY key.
*/
private async getAffectedVersions(
organizationId: string,
projectId: string,
environmentId: string,
fingerprint: string
): Promise<string[]> {
const queryBuilder = this.logsClickhouse.errors.affectedVersionsQueryBuilder();
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint });
queryBuilder.where("task_version != ''");
queryBuilder.limit(100);
const [queryError, records] = await queryBuilder.execute();
if (queryError || !records) {
return [];
}
const versions = records.map((r) => r.task_version).filter((v) => v.length > 0);
return sortVersionsDescending(versions).slice(0, 5);
}
private async getState(
environmentId: string,
taskIdentifier: string | undefined,
fingerprint: string
): Promise<ErrorGroupState | null> {
const row = await this.replica.errorGroupState.findFirst({
where: {
environmentId,
...(taskIdentifier ? { taskIdentifier } : {}),
errorFingerprint: fingerprint,
},
select: {
status: true,
resolvedAt: true,
resolvedInVersion: true,
resolvedBy: true,
ignoredAt: true,
ignoredUntil: true,
ignoredReason: true,
ignoredByUserId: true,
ignoredUntilOccurrenceRate: true,
ignoredUntilTotalOccurrences: true,
ignoredAtOccurrenceCount: true,
},
});
if (!row) {
return null;
}
let ignoredByUserDisplayName: string | null = null;
if (row.ignoredByUserId) {
const user = await this.replica.user.findFirst({
where: { id: row.ignoredByUserId },
select: { displayName: true, name: true, email: true },
});
if (user) {
ignoredByUserDisplayName = user.displayName ?? user.name ?? user.email;
}
}
return {
status: row.status,
resolvedAt: row.resolvedAt,
resolvedInVersion: row.resolvedInVersion,
resolvedBy: row.resolvedBy,
ignoredAt: row.ignoredAt,
ignoredUntil: row.ignoredUntil,
ignoredReason: row.ignoredReason,
ignoredByUserId: row.ignoredByUserId,
ignoredByUserDisplayName,
ignoredUntilOccurrenceRate: row.ignoredUntilOccurrenceRate,
ignoredUntilTotalOccurrences: row.ignoredUntilTotalOccurrences,
ignoredAtOccurrenceCount: row.ignoredAtOccurrenceCount
? Number(row.ignoredAtOccurrenceCount)
: null,
};
}
private async getRunList(
organizationId: string,
environmentId: string,
options: {
userId?: string;
projectId: string;
fingerprint: string;
versions?: string[];
pageSize: number;
from?: number;
to?: number;
cursor?: string;
direction?: Direction;
}
): Promise<NextRunList | undefined> {
const runListPresenter = new NextRunListPresenter(this.replica, this.clickhouse);
const result = await runListPresenter.call(organizationId, environmentId, {
userId: options.userId,
projectId: options.projectId,
rootOnly: false,
errorId: ErrorId.toFriendlyId(options.fingerprint),
versions: options.versions,
pageSize: options.pageSize,
from: options.from,
to: options.to,
cursor: options.cursor,
direction: options.direction,
});
if (result.runs.length === 0) {
return undefined;
}
return result;
}
}
@@ -0,0 +1,568 @@
import { z } from "zod";
import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse";
import { TimeGranularity } from "~/utils/timeGranularity";
const errorsListGranularity = new TimeGranularity([
{ max: "2h", granularity: "1m" },
{ max: "2d", granularity: "1h" },
{ max: "2w", granularity: "1d" },
{ max: "3 months", granularity: "1w" },
{ max: "Infinity", granularity: "30d" },
]);
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
import { type Direction } from "~/components/ListPagination";
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
export type ErrorsListOptions = {
userId?: string;
projectId: string;
// filters
tasks?: string[];
versions?: string[];
statuses?: ErrorGroupStatus[];
period?: string;
from?: number;
to?: number;
defaultPeriod?: string;
retentionLimitDays?: number;
// search
search?: string;
// pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
export const ErrorsListOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
tasks: z.array(z.string()).optional(),
versions: z.array(z.string()).optional(),
statuses: z.array(z.enum(["UNRESOLVED", "RESOLVED", "IGNORED"])).optional(),
period: z.string().optional(),
from: z.number().int().nonnegative().optional(),
to: z.number().int().nonnegative().optional(),
defaultPeriod: z.string().optional(),
retentionLimitDays: z.number().int().positive().optional(),
search: z.string().max(1000).optional(),
direction: z.enum(["forward", "backward"]).optional(),
cursor: z.string().optional(),
pageSize: z.number().int().positive().max(1000).optional(),
});
const DEFAULT_PAGE_SIZE = 25;
export type ErrorsList = Awaited<ReturnType<ErrorsListPresenter["call"]>>;
export type ErrorGroup = ErrorsList["errorGroups"][0];
export type ErrorsListAppliedFilters = ErrorsList["filters"];
export type ErrorOccurrences = Awaited<ReturnType<ErrorsListPresenter["getOccurrences"]>>;
export type ErrorOccurrenceActivity = ErrorOccurrences["data"][string];
type ErrorGroupCursor = {
occurrenceCount: number;
fingerprint: string;
taskIdentifier: string;
};
const ErrorGroupCursorSchema = z.object({
occurrenceCount: z.number(),
fingerprint: z.string(),
taskIdentifier: z.string(),
});
function encodeCursor(cursor: ErrorGroupCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString("base64");
}
function decodeCursor(cursor: string): ErrorGroupCursor | null {
try {
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
const parsed = JSON.parse(decoded);
const validated = ErrorGroupCursorSchema.safeParse(parsed);
if (!validated.success) {
return null;
}
return validated.data as ErrorGroupCursor;
} catch {
return null;
}
}
function cursorFromRow(row: {
occurrence_count: number;
error_fingerprint: string;
task_identifier: string;
}): string {
return encodeCursor({
occurrenceCount: row.occurrence_count,
fingerprint: row.error_fingerprint,
taskIdentifier: row.task_identifier,
});
}
function parseClickHouseDateTime(value: string): Date {
const asNum = Number(value);
if (!isNaN(asNum) && asNum > 1e12) {
return new Date(asNum);
}
return new Date(value.replace(" ", "T") + "Z");
}
function escapeClickHouseString(val: string): string {
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
export class ErrorsListPresenter extends BasePresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {
super(undefined, replica);
}
public async call(
organizationId: string,
environmentId: string,
{
userId,
projectId,
tasks,
versions,
statuses,
period,
search,
from,
to,
direction,
cursor,
pageSize = DEFAULT_PAGE_SIZE,
defaultPeriod,
retentionLimitDays,
}: ErrorsListOptions
) {
const time = timeFilterFromTo({
period,
from,
to,
defaultPeriod: defaultPeriod ?? "1d",
});
let effectiveFrom = time.from;
let effectiveTo = time.to;
let wasClampedByRetention = false;
if (retentionLimitDays !== undefined && effectiveFrom) {
const retentionCutoffDate = new Date(Date.now() - retentionLimitDays * 24 * 60 * 60 * 1000);
if (effectiveFrom < retentionCutoffDate) {
effectiveFrom = retentionCutoffDate;
wasClampedByRetention = true;
}
}
const hasFilters =
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
(search !== undefined && search !== "") ||
(statuses !== undefined && statuses.length > 0);
const possibleTasksAsync = getTaskIdentifiers(environmentId);
// Pre-filter by status: since status lives in Postgres (ErrorGroupState) and the error
// list comes from ClickHouse, we resolve inclusion/exclusion sets upfront so that
// ClickHouse pagination operates on the correctly filtered dataset.
const statusFilterAsync = this.resolveStatusFilter(environmentId, statuses);
const [possibleTasks, displayableEnvironment, statusFilter] = await Promise.all([
possibleTasksAsync,
findDisplayableEnvironment(environmentId, userId),
statusFilterAsync,
]);
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
if (statusFilter.empty) {
return {
errorGroups: [],
pagination: {
next: undefined,
previous: undefined,
},
filters: {
tasks,
versions,
statuses,
search,
period: time,
from: effectiveFrom,
to: effectiveTo,
hasFilters,
possibleTasks,
wasClampedByRetention,
},
};
}
// Query the per-minute error_occurrences_v1 table for time-scoped counts
const queryBuilder = this.clickhouse.errors.occurrencesListQueryBuilder();
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
// Precise time range filtering via WHERE on the minute column
queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({fromTimeMs: Int64}))", {
fromTimeMs: effectiveFrom.getTime(),
});
queryBuilder.where("minute <= toStartOfMinute(fromUnixTimestamp64Milli({toTimeMs: Int64}))", {
toTimeMs: effectiveTo.getTime(),
});
if (tasks && tasks.length > 0) {
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks });
}
if (versions && versions.length > 0) {
queryBuilder.where("task_version IN {versions: Array(String)}", { versions });
}
if (statusFilter.includeKeys) {
queryBuilder.where(
"concat(task_identifier, '::', error_fingerprint) IN {statusIncludeKeys: Array(String)}",
{ statusIncludeKeys: statusFilter.includeKeys }
);
}
if (statusFilter.excludeKeys) {
queryBuilder.where(
"concat(task_identifier, '::', error_fingerprint) NOT IN {statusExcludeKeys: Array(String)}",
{ statusExcludeKeys: statusFilter.excludeKeys }
);
}
queryBuilder.groupBy("error_fingerprint, task_identifier");
// Text search via HAVING (operates on aggregated values)
if (search && search.trim() !== "") {
const searchTerm = escapeClickHouseString(search.trim()).toLowerCase();
queryBuilder.having(
"(lower(error_type) like {searchPattern: String} OR lower(error_message) like {searchPattern: String})",
{
searchPattern: `%${searchTerm}%`,
}
);
}
const isBackward = direction === "backward";
const decodedCursor = cursor ? decodeCursor(cursor) : null;
if (decodedCursor) {
const cmp = isBackward ? ">" : "<";
queryBuilder.having(
`(occurrence_count ${cmp} {cursorOccurrenceCount: UInt64}
OR (occurrence_count = {cursorOccurrenceCount: UInt64} AND error_fingerprint ${cmp} {cursorFingerprint: String})
OR (occurrence_count = {cursorOccurrenceCount: UInt64} AND error_fingerprint = {cursorFingerprint: String} AND task_identifier ${cmp} {cursorTaskIdentifier: String}))`,
{
cursorOccurrenceCount: decodedCursor.occurrenceCount,
cursorFingerprint: decodedCursor.fingerprint,
cursorTaskIdentifier: decodedCursor.taskIdentifier,
}
);
}
const sortDir = isBackward ? "ASC" : "DESC";
queryBuilder.orderBy(
`occurrence_count ${sortDir}, error_fingerprint ${sortDir}, task_identifier ${sortDir}`
);
queryBuilder.limit(pageSize + 1);
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
const results = records || [];
const hasMore = results.length > pageSize;
const page = results.slice(0, pageSize);
if (isBackward) {
page.reverse();
}
let nextCursor: string | undefined;
let previousCursor: string | undefined;
if (isBackward) {
previousCursor = hasMore && page.length > 0 ? cursorFromRow(page[0]) : undefined;
nextCursor = page.length > 0 ? cursorFromRow(page[page.length - 1]) : undefined;
} else {
previousCursor = decodedCursor && page.length > 0 ? cursorFromRow(page[0]) : undefined;
nextCursor = hasMore && page.length > 0 ? cursorFromRow(page[page.length - 1]) : undefined;
}
const errorGroups = page;
// Fetch global first_seen / last_seen from the errors_v1 summary table
const fingerprints = errorGroups.map((e) => e.error_fingerprint);
const [globalSummaryMap, stateMap] = await Promise.all([
this.getGlobalSummary(organizationId, projectId, environmentId, fingerprints),
this.getErrorGroupStates(environmentId, errorGroups),
]);
let transformedErrorGroups = errorGroups.map((error) => {
const global = globalSummaryMap.get(error.error_fingerprint);
const state = stateMap.get(`${error.task_identifier}:${error.error_fingerprint}`);
return {
errorType: error.error_type,
errorMessage: error.error_message,
fingerprint: error.error_fingerprint,
taskIdentifier: error.task_identifier,
firstSeen: global?.firstSeen ?? new Date(),
lastSeen: global?.lastSeen ?? new Date(),
count: error.occurrence_count,
status: state?.status ?? "UNRESOLVED",
resolvedAt: state?.resolvedAt ?? null,
ignoredUntil: state?.ignoredUntil ?? null,
};
});
return {
errorGroups: transformedErrorGroups,
pagination: {
next: nextCursor,
previous: previousCursor,
},
filters: {
tasks,
versions,
statuses,
search,
period: time,
from: effectiveFrom,
to: effectiveTo,
hasFilters,
possibleTasks,
wasClampedByRetention,
},
};
}
/**
* Returns bucketed occurrence counts for the given fingerprints over a time range.
* Granularity is determined automatically from the range span.
*/
public async getOccurrences(
organizationId: string,
projectId: string,
environmentId: string,
fingerprints: string[],
from: Date,
to: Date
): Promise<{
data: Record<string, Array<{ date: Date; count: number }>>;
}> {
if (fingerprints.length === 0) {
return { data: {} };
}
const granularityMs = errorsListGranularity.getTimeGranularityMs(from, to);
const intervalExpr = msToClickHouseInterval(granularityMs);
const queryBuilder = this.clickhouse.errors.createOccurrencesQueryBuilder(intervalExpr);
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("error_fingerprint IN {fingerprints: Array(String)}", { fingerprints });
queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({fromTimeMs: Int64}))", {
fromTimeMs: from.getTime(),
});
queryBuilder.where("minute <= toStartOfMinute(fromUnixTimestamp64Milli({toTimeMs: Int64}))", {
toTimeMs: to.getTime(),
});
queryBuilder.groupBy("error_fingerprint, bucket_epoch");
queryBuilder.orderBy("error_fingerprint ASC, bucket_epoch ASC");
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
// Build time buckets covering the full range
const buckets: number[] = [];
const startEpoch = Math.floor(from.getTime() / granularityMs) * (granularityMs / 1000);
const endEpoch = Math.ceil(to.getTime() / 1000);
for (let epoch = startEpoch; epoch <= endEpoch; epoch += granularityMs / 1000) {
buckets.push(epoch);
}
// Index results by fingerprint -> epoch -> count
const grouped = new Map<string, Map<number, number>>();
for (const row of records ?? []) {
let byBucket = grouped.get(row.error_fingerprint);
if (!byBucket) {
byBucket = new Map();
grouped.set(row.error_fingerprint, byBucket);
}
byBucket.set(row.bucket_epoch, (byBucket.get(row.bucket_epoch) ?? 0) + row.count);
}
const data: Record<string, Array<{ date: Date; count: number }>> = {};
for (const fp of fingerprints) {
const byBucket = grouped.get(fp);
data[fp] = buckets.map((epoch) => ({
date: new Date(epoch * 1000),
count: byBucket?.get(epoch) ?? 0,
}));
}
return { data };
}
/**
* Determines which (task, fingerprint) pairs to include or exclude from the ClickHouse
* query based on the requested status filter. Since status lives in Postgres and errors
* live in ClickHouse, we resolve the filter set here so ClickHouse pagination is correct.
*
* - UNRESOLVED is the default (no ErrorGroupState row), so filtering FOR it means
* excluding groups with non-matching explicit statuses.
* - RESOLVED/IGNORED are explicit, so filtering for them means including only matching groups.
*/
private async resolveStatusFilter(
environmentId: string,
statuses?: ErrorGroupStatus[]
): Promise<{
includeKeys?: string[];
excludeKeys?: string[];
empty: boolean;
}> {
if (!statuses || statuses.length === 0) {
return { empty: false };
}
const allStatuses: ErrorGroupStatus[] = ["UNRESOLVED", "RESOLVED", "IGNORED"];
const excludedStatuses = allStatuses.filter((s) => !statuses.includes(s));
if (excludedStatuses.length === 0) {
return { empty: false };
}
if (statuses.includes("UNRESOLVED")) {
const excluded = await this.replica.errorGroupState.findMany({
where: { environmentId, status: { in: excludedStatuses } },
select: { taskIdentifier: true, errorFingerprint: true },
});
if (excluded.length === 0) {
return { empty: false };
}
return {
excludeKeys: excluded.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`),
empty: false,
};
}
const included = await this.replica.errorGroupState.findMany({
where: { environmentId, status: { in: statuses } },
select: { taskIdentifier: true, errorFingerprint: true },
});
if (included.length === 0) {
return { empty: true };
}
return {
includeKeys: included.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`),
empty: false,
};
}
/**
* Batch-fetch ErrorGroupState rows from Postgres for the given ClickHouse error groups.
* Returns a map keyed by `${taskIdentifier}:${errorFingerprint}`.
*/
private async getErrorGroupStates(
environmentId: string,
errorGroups: Array<{ task_identifier: string; error_fingerprint: string }>
) {
type StateValue = {
status: ErrorGroupStatus;
resolvedAt: Date | null;
ignoredUntil: Date | null;
};
const result = new Map<string, StateValue>();
if (errorGroups.length === 0) return result;
const states = await this.replica.errorGroupState.findMany({
where: {
environmentId,
OR: errorGroups.map((e) => ({
taskIdentifier: e.task_identifier,
errorFingerprint: e.error_fingerprint,
})),
},
select: {
taskIdentifier: true,
errorFingerprint: true,
status: true,
resolvedAt: true,
ignoredUntil: true,
},
});
for (const state of states) {
result.set(`${state.taskIdentifier}:${state.errorFingerprint}`, {
status: state.status,
resolvedAt: state.resolvedAt,
ignoredUntil: state.ignoredUntil,
});
}
return result;
}
/**
* Fetches global first_seen / last_seen for a set of fingerprints from errors_v1.
*/
private async getGlobalSummary(
organizationId: string,
projectId: string,
environmentId: string,
fingerprints: string[]
): Promise<Map<string, { firstSeen: Date; lastSeen: Date }>> {
const result = new Map<string, { firstSeen: Date; lastSeen: Date }>();
if (fingerprints.length === 0) return result;
const queryBuilder = this.clickhouse.errors.listQueryBuilder();
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("error_fingerprint IN {fingerprints: Array(String)}", { fingerprints });
queryBuilder.groupBy("error_fingerprint, task_identifier");
const [queryError, records] = await queryBuilder.execute();
if (queryError || !records) return result;
for (const record of records) {
const firstSeen = parseClickHouseDateTime(record.first_seen);
const lastSeen = parseClickHouseDateTime(record.last_seen);
const existing = result.get(record.error_fingerprint);
if (existing) {
if (firstSeen < existing.firstSeen) existing.firstSeen = firstSeen;
if (lastSeen > existing.lastSeen) existing.lastSeen = lastSeen;
} else {
result.set(record.error_fingerprint, { firstSeen, lastSeen });
}
}
return result;
}
}
@@ -0,0 +1,137 @@
import { type PrismaClient } from "@trigger.dev/database";
import { fromPromise, ok, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { BasePresenter } from "./basePresenter.server";
type GitHubSettingsOptions = {
projectId: string;
organizationId: string;
};
export class GitHubSettingsPresenter extends BasePresenter {
public call({ projectId, organizationId }: GitHubSettingsOptions) {
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
if (!githubAppEnabled) {
return ok({
enabled: false,
connectedRepository: undefined,
installations: undefined,
isPreviewEnvironmentEnabled: undefined,
});
}
const findConnectedGithubRepository = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
createdAt: true,
repository: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
},
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((connectedGithubRepository) => {
if (!connectedGithubRepository) {
return undefined;
}
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedGithubRepository.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedGithubRepository,
branchTracking,
};
});
const listGithubAppInstallations = () =>
fromPromise(
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
const isPreviewEnvironmentEnabled = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
return ResultAsync.combine([
isPreviewEnvironmentEnabled(),
findConnectedGithubRepository(),
listGithubAppInstallations(),
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
enabled: true,
connectedRepository: connectedGithubRepository,
installations: githubAppInstallations,
isPreviewEnvironmentEnabled,
}));
}
}
@@ -0,0 +1,524 @@
import { Ratelimit } from "@upstash/ratelimit";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import {
RateLimiterConfig,
createLimiterFromConfig,
type RateLimitTokenBucketConfig,
} from "~/services/authorizationRateLimitMiddleware.server";
import { createRedisRateLimitClient, type Duration } from "~/services/rateLimiter.server";
import { BasePresenter } from "./basePresenter.server";
import { singleton } from "~/utils/singleton";
import { logger } from "~/services/logger.server";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
import { engine } from "~/v3/runEngine.server";
import { getQueueSizeLimit, getQueueSizeLimitSource } from "~/v3/utils/queueLimits.server";
// Create a singleton Redis client for rate limit queries
const rateLimitRedisClient = singleton("rateLimitQueryRedisClient", () =>
createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
})
);
// Types for rate limit display
export type RateLimitInfo = {
name: string;
description: string;
config: RateLimiterConfig;
currentTokens: number | null;
};
// Types for quota display
export type QuotaInfo = {
name: string;
description: string;
limit: number | null;
currentUsage: number;
source: "default" | "plan" | "override";
canExceed?: boolean;
isUpgradable?: boolean;
};
// Types for feature flags
export type FeatureInfo = {
name: string;
description: string;
enabled: boolean;
value?: string | number;
};
export type LimitsResult = {
rateLimits: {
api: RateLimitInfo;
batch: RateLimitInfo;
};
quotas: {
projects: QuotaInfo;
schedules: QuotaInfo | null;
teamMembers: QuotaInfo | null;
alerts: QuotaInfo | null;
branches: QuotaInfo | null;
logRetentionDays: QuotaInfo | null;
realtimeConnections: QuotaInfo | null;
batchProcessingConcurrency: QuotaInfo;
queueSize: QuotaInfo;
metricDashboards: QuotaInfo | null;
metricWidgetsPerDashboard: QuotaInfo | null;
queryPeriodDays: QuotaInfo | null;
};
features: {
hasStagingEnvironment: FeatureInfo;
support: FeatureInfo;
includedUsage: FeatureInfo;
};
planName: string | null;
organizationId: string;
isOnTopPlan: boolean;
};
export class LimitsPresenter extends BasePresenter {
public async call({
organizationId,
projectId,
environmentId,
environmentType,
environmentApiKey,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
environmentApiKey: string;
}): Promise<LimitsResult> {
// Get organization with all limit-related fields
const organization = await this._replica.organization.findFirstOrThrow({
where: { id: organizationId },
select: {
id: true,
maximumConcurrencyLimit: true,
maximumProjectCount: true,
maximumDevQueueSize: true,
maximumDeployedQueueSize: true,
apiRateLimiterConfig: true,
batchRateLimitConfig: true,
batchQueueConcurrencyConfig: true,
_count: {
select: {
projects: {
where: { deletedAt: null },
},
members: true,
},
},
},
});
// Get current plan from billing service
const currentPlan = await getCurrentPlan(organizationId);
const limits = currentPlan?.v3Subscription?.plan?.limits;
const isOnTopPlan = currentPlan?.v3Subscription?.plan?.code === "v3_pro_1";
// Resolve rate limit configs (org override or default)
const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig);
const batchRateLimitConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
// Resolve batch concurrency config
const batchConcurrencyConfig = resolveBatchConcurrencyConfig(
organization.batchQueueConcurrencyConfig
);
const batchConcurrencySource = organization.batchQueueConcurrencyConfig
? "override"
: "default";
// Get schedule count for this org
const scheduleCount = await CheckScheduleService.getUsedSchedulesCount({
prisma: this._replica,
projectId,
});
// Get alert channel count for this org
const alertChannelCount = await this._replica.projectAlertChannel.count({
where: {
projectId,
},
});
// Get active branches count for this org (uses @@index([organizationId]))
const activeBranchCount = await this._replica.runtimeEnvironment.count({
where: {
projectId,
branchName: {
not: null,
},
archivedAt: null,
},
});
// Get metric dashboard count for this org
const metricDashboardCount = await this._replica.metricsDashboard.count({
where: { organizationId },
});
// Get current rate limit tokens for this environment's API key
const apiRateLimitTokens = await getRateLimitRemainingTokens(
"api",
environmentApiKey,
apiRateLimitConfig
);
// Batch rate limiter uses environment ID directly (not hashed) with a different key prefix
const batchRateLimitTokens = await getBatchRateLimitRemainingTokens(
environmentId,
batchRateLimitConfig
);
// Get current queue size for this environment
// We need the runtime environment fields for the engine query
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
where: { id: environmentId },
select: {
id: true,
maximumConcurrencyLimit: true,
concurrencyLimitBurstFactor: true,
},
});
let currentQueueSize = 0;
if (runtimeEnv) {
const engineEnv = {
id: runtimeEnv.id,
type: environmentType,
maximumConcurrencyLimit: runtimeEnv.maximumConcurrencyLimit,
concurrencyLimitBurstFactor: runtimeEnv.concurrencyLimitBurstFactor,
organization: { id: organizationId },
project: { id: projectId },
};
currentQueueSize = (await engine.lengthOfEnvQueue(engineEnv)) ?? 0;
}
// Get plan-level limits
const schedulesLimit = limits?.schedules?.number ?? null;
const teamMembersLimit = limits?.teamMembers?.number ?? null;
const alertsLimit = limits?.alerts?.number ?? null;
const branchesLimit = limits?.branches?.number ?? null;
const logRetentionDaysLimit = limits?.logRetentionDays?.number ?? null;
const realtimeConnectionsLimit = limits?.realtimeConcurrentConnections?.number ?? null;
const metricDashboardsLimit = limits?.metricDashboards?.number ?? null;
const metricWidgetsPerDashboardLimit = limits?.metricWidgetsPerDashboard?.number ?? null;
const queryPeriodDaysLimit = limits?.queryPeriodDays?.number ?? null;
const includedUsage = limits?.includedUsage ?? null;
const hasStagingEnvironment = limits?.hasStagingEnvironment ?? false;
const supportLevel = limits?.support ?? "community";
return {
isOnTopPlan,
rateLimits: {
api: {
name: "API rate limit",
description: "Rate limit for API requests (trigger, batch, etc.)",
config: apiRateLimitConfig,
currentTokens: apiRateLimitTokens,
},
batch: {
name: "Batch rate limit",
description: "Rate limit for batch trigger operations",
config: batchRateLimitConfig,
currentTokens: batchRateLimitTokens,
},
},
quotas: {
projects: {
name: "Projects",
description: "Maximum number of projects in this organization",
limit: organization.maximumProjectCount,
currentUsage: organization._count.projects,
source: "default",
isUpgradable: true,
},
schedules:
schedulesLimit !== null
? {
name: "Schedules",
description: "Maximum number of schedules per project",
limit: schedulesLimit,
currentUsage: scheduleCount,
source: "plan",
canExceed: limits?.schedules?.canExceed,
isUpgradable: true,
}
: null,
teamMembers:
teamMembersLimit !== null
? {
name: "Team members",
description: "Maximum number of team members in this organization",
limit: teamMembersLimit,
currentUsage: organization._count.members,
source: "plan",
canExceed: limits?.teamMembers?.canExceed,
isUpgradable: true,
}
: null,
alerts:
alertsLimit !== null
? {
name: "Alert channels",
description: "Maximum number of alert channels per project",
limit: alertsLimit,
currentUsage: alertChannelCount,
source: "plan",
canExceed: limits?.alerts?.canExceed,
isUpgradable: true,
}
: null,
branches:
branchesLimit !== null
? {
name: "Preview branches",
description: "Maximum number of active preview branches per project",
limit: branchesLimit,
currentUsage: activeBranchCount,
source: "plan",
canExceed: limits?.branches?.canExceed,
isUpgradable: true,
}
: null,
logRetentionDays:
logRetentionDaysLimit !== null
? {
name: "Log retention",
description: "Number of days logs are retained",
limit: logRetentionDaysLimit,
currentUsage: 0, // Not applicable - this is a duration, not a count
source: "plan",
}
: null,
realtimeConnections:
realtimeConnectionsLimit !== null
? {
name: "Realtime connections",
description: "Maximum concurrent Realtime connections",
limit: realtimeConnectionsLimit,
currentUsage: 0, // Would need to query realtime service for this
source: "plan",
canExceed: limits?.realtimeConcurrentConnections?.canExceed,
isUpgradable: true,
}
: null,
batchProcessingConcurrency: {
name: "Batch trigger processing concurrency",
description:
"When you send a batch trigger, we convert it into individual runs in parallel. This is the maximum number of batches being converted into runs at once. It does not limit how many batch runs can be executing.",
limit: batchConcurrencyConfig.processingConcurrency,
currentUsage: 0,
source: batchConcurrencySource,
canExceed: true,
isUpgradable: true,
},
queueSize: {
name: "Max queued runs",
description: "Maximum pending runs per individual queue in this environment",
limit: getQueueSizeLimit(environmentType, organization),
currentUsage: currentQueueSize,
source: getQueueSizeLimitSource(environmentType, organization),
isUpgradable: true,
},
metricDashboards:
metricDashboardsLimit !== null
? {
name: "Metric dashboards",
description: "Maximum number of custom metric dashboards per organization",
limit: metricDashboardsLimit,
currentUsage: metricDashboardCount,
source: "plan",
canExceed: limits?.metricDashboards?.canExceed,
isUpgradable: true,
}
: null,
metricWidgetsPerDashboard:
metricWidgetsPerDashboardLimit !== null
? {
name: "Charts per dashboard",
description: "Maximum number of charts per metrics dashboard",
limit: metricWidgetsPerDashboardLimit,
currentUsage: 0, // Varies per dashboard
source: "plan",
canExceed: limits?.metricWidgetsPerDashboard?.canExceed,
isUpgradable: true,
}
: null,
queryPeriodDays:
queryPeriodDaysLimit !== null
? {
name: "Query period",
description: "Maximum number of days a query can look back",
limit: queryPeriodDaysLimit,
currentUsage: 0, // Not applicable - this is a duration, not a count
source: "plan",
}
: null,
},
features: {
hasStagingEnvironment: {
name: "Staging/Preview environments",
description: "Access to staging/preview environments for testing before production",
enabled: hasStagingEnvironment,
},
support: {
name: "Support level",
description: "Type of support available for your plan",
enabled: true,
value: supportLevel === "slack" ? "Priority" : "Community",
},
includedUsage: {
name: "Included compute",
description: "Monthly included compute credits",
enabled: includedUsage !== null && includedUsage > 0,
value: includedUsage ?? 0,
},
},
planName: currentPlan?.v3Subscription?.plan?.title ?? null,
organizationId,
};
}
}
function resolveApiRateLimitConfig(apiRateLimiterConfig?: unknown): RateLimiterConfig {
const defaultConfig: RateLimitTokenBucketConfig = {
type: "tokenBucket",
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.API_RATE_LIMIT_MAX,
};
if (!apiRateLimiterConfig) {
return defaultConfig;
}
const parsed = RateLimiterConfig.safeParse(apiRateLimiterConfig);
if (!parsed.success) {
return defaultConfig;
}
return parsed.data;
}
function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig {
const defaultConfig: RateLimitTokenBucketConfig = {
type: "tokenBucket",
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.BATCH_RATE_LIMIT_MAX,
};
if (!batchRateLimitConfig) {
return defaultConfig;
}
const parsed = RateLimiterConfig.safeParse(batchRateLimitConfig);
if (!parsed.success) {
return defaultConfig;
}
return parsed.data;
}
function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): {
processingConcurrency: number;
} {
const defaultConfig = {
processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
};
if (!batchConcurrencyConfig) {
return defaultConfig;
}
if (typeof batchConcurrencyConfig === "object" && batchConcurrencyConfig !== null) {
const config = batchConcurrencyConfig as Record<string, unknown>;
if (typeof config.processingConcurrency === "number") {
return { processingConcurrency: config.processingConcurrency };
}
}
return defaultConfig;
}
/**
* Query the current remaining tokens for a rate limiter using the Upstash getRemaining method.
* This uses the same configuration and hashing logic as the rate limit middleware.
*/
async function getRateLimitRemainingTokens(
keyPrefix: string,
apiKey: string,
config: RateLimiterConfig
): Promise<number | null> {
try {
// Hash the authorization header the same way the rate limiter does
const authorizationValue = `Bearer ${apiKey}`;
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedKey = hash.digest("hex");
// Create a Ratelimit instance with the same configuration
const limiter = createLimiterFromConfig(config);
const ratelimit = new Ratelimit({
redis: rateLimitRedisClient,
limiter,
ephemeralCache: new Map(),
analytics: false,
prefix: `ratelimit:${keyPrefix}`,
});
// Use the getRemaining method to get the current remaining tokens
// getRemaining returns a Promise<number>
const remaining = await ratelimit.getRemaining(hashedKey);
return remaining;
} catch (error) {
logger.warn("Failed to get rate limit remaining tokens", {
keyPrefix,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Query the current remaining tokens for the batch rate limiter.
* The batch rate limiter uses environment ID directly (not hashed) and has a different key prefix.
*/
async function getBatchRateLimitRemainingTokens(
environmentId: string,
config: RateLimiterConfig
): Promise<number | null> {
try {
// Create a Ratelimit instance with the same configuration as the batch rate limiter
const limiter = createLimiterFromConfig(config);
const ratelimit = new Ratelimit({
redis: rateLimitRedisClient,
limiter,
ephemeralCache: new Map(),
analytics: false,
// The batch rate limiter uses "ratelimit:batch" as keyPrefix in RateLimiter,
// which adds another "ratelimit:" prefix, resulting in "ratelimit:ratelimit:batch"
prefix: `ratelimit:ratelimit:batch`,
});
// Batch rate limiter uses environment ID directly (not hashed)
const remaining = await ratelimit.getRemaining(environmentId);
return remaining;
} catch (error) {
logger.warn("Failed to get batch rate limit remaining tokens", {
environmentId,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
@@ -0,0 +1,107 @@
import { type ClickHouse } from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { convertClickhouseDateTime64ToJsDate } from "~/v3/eventRepository/clickhouseEventRepository.server";
import { kindToLevel } from "~/utils/logUtils";
import { getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
export type LogDetailOptions = {
environmentId: string;
organizationId: string;
projectId: string;
spanId: string;
traceId: string;
// The exact start_time from the log id - used to uniquely identify the event
startTime: string;
};
export type LogDetail = Awaited<ReturnType<LogDetailPresenter["call"]>>;
export class LogDetailPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {}
public async call(options: LogDetailOptions) {
const { environmentId, organizationId, projectId, spanId, traceId, startTime } = options;
// Determine which store to use based on organization configuration
const { store } = await getConfiguredEventRepository(organizationId);
// Throw error if postgres is detected
if (store === "postgres") {
throw new ServiceValidationError(
"Log details are not available for PostgreSQL event store. Please contact support."
);
}
const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder();
// Required filters - spanId, traceId, and startTime uniquely identify the log
// Multiple events can share the same spanId (span, span events, logs), so startTime is needed
queryBuilder.where("environment_id = {environmentId: String}", {
environmentId,
});
queryBuilder.where("organization_id = {organizationId: String}", {
organizationId,
});
queryBuilder.where("project_id = {projectId: String}", { projectId });
queryBuilder.where("span_id = {spanId: String}", { spanId });
queryBuilder.where("trace_id = {traceId: String}", { traceId });
queryBuilder.where("start_time = {startTime: String}", { startTime });
queryBuilder.limit(1);
// Execute query
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (!records || records.length === 0) {
return null;
}
const log = records[0];
let parsedAttributes: Record<string, unknown> = {};
let rawAttributesString = "";
try {
// Handle attributes_text which is a string
if (log.attributes_text) {
parsedAttributes = JSON.parse(log.attributes_text) as Record<string, unknown>;
rawAttributesString = log.attributes_text;
}
} catch {
// Ignore parse errors
}
const durationMs =
(typeof log.duration === "number" ? log.duration : Number(log.duration)) / 1_000_000;
return {
// Use :: separator to match LogsListPresenter format
id: `${log.trace_id}::${log.span_id}::${log.run_id}::${log.start_time}`,
runId: log.run_id,
taskIdentifier: log.task_identifier,
startTime: convertClickhouseDateTime64ToJsDate(log.start_time).toISOString(),
triggeredTimestamp: new Date(
convertClickhouseDateTime64ToJsDate(log.start_time).getTime() + durationMs
).toISOString(),
traceId: log.trace_id,
spanId: log.span_id,
parentSpanId: log.parent_span_id || null,
message: log.message,
kind: log.kind,
status: log.status,
duration: typeof log.duration === "number" ? log.duration : Number(log.duration),
level: kindToLevel(log.kind, log.status),
attributes: parsedAttributes,
// Raw strings for display
rawAttributes: rawAttributesString,
};
}
}
@@ -0,0 +1,491 @@
import {
type ClickHouse,
type LogsSearchListResult,
type WhereCondition,
} from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { z } from "zod";
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import { type Direction } from "~/components/ListPagination";
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { env } from "~/env.server";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils";
import {
convertClickhouseDateTime64ToJsDate,
convertDateToClickhouseDateTime,
} from "~/v3/eventRepository/clickhouseEventRepository.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
export type { LogLevel };
type ErrorAttributes = {
error?: {
message?: unknown;
};
[key: string]: unknown;
};
function escapeClickHouseString(val: string): string {
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
export type LogsListOptions = {
userId?: string;
projectId: string;
// filters
tasks?: string[];
runId?: string;
period?: string;
from?: number;
to?: number;
levels?: LogLevel[];
defaultPeriod?: string;
retentionLimitDays?: number;
// search
search?: string;
// pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
export const LogsListOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
tasks: z.array(z.string()).optional(),
runId: z.string().optional(),
period: z.string().optional(),
from: z.number().int().nonnegative().optional(),
to: z.number().int().nonnegative().optional(),
levels: z.array(LogLevelSchema).optional(),
defaultPeriod: z.string().optional(),
retentionLimitDays: z.number().int().positive().optional(),
search: z.string().max(1000).optional(),
direction: z.enum(["forward", "backward"]).optional(),
cursor: z.string().optional(),
pageSize: z.number().int().positive().max(1000).optional(),
});
const DAY_MS = 24 * 60 * 60 * 1000;
export type LogsList = Awaited<ReturnType<LogsListPresenter["call"]>>;
export type LogEntry = LogsList["logs"][0];
export type LogsListAppliedFilters = LogsList["filters"];
// Bump when the cursor shape changes so stale cursors are ignored (reset to the first page)
// rather than misparsed.
const LOG_CURSOR_VERSION = 2;
// Cursor is a base64 encoded JSON of the pagination keys
type LogCursor = {
v: number;
organizationId: string;
environmentId: string;
triggeredTimestamp: string; // DateTime64(9) string
traceId: string;
spanId: string;
};
const LogCursorSchema = z.object({
v: z.literal(LOG_CURSOR_VERSION),
organizationId: z.string(),
environmentId: z.string(),
triggeredTimestamp: z.string(),
traceId: z.string(),
spanId: z.string(),
});
function encodeCursor(cursor: LogCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString("base64");
}
function decodeCursor(cursor: string): LogCursor | null {
try {
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
const parsed = JSON.parse(decoded);
const validated = LogCursorSchema.safeParse(parsed);
if (!validated.success) {
return null;
}
return validated.data;
} catch {
return null;
}
}
// Ordered list of lower bounds to try, narrowest (most recent) first, ending at the user's
// requested floor (or undefined for an unbounded-below window). Because rows are returned
// newest-first, a narrow window that already fills a page returns the exact same top rows the
// full window would, so widening only happens when a page comes back short.
function buildProbeFloors(
ceil: Date,
hardFloor: Date | undefined,
stepDays: number[]
): (Date | undefined)[] {
const floors: (Date | undefined)[] = [];
for (const days of stepDays) {
let candidate = new Date(ceil.getTime() - days * DAY_MS);
if (hardFloor && candidate <= hardFloor) {
candidate = hardFloor;
}
floors.push(candidate);
if (hardFloor && candidate.getTime() === hardFloor.getTime()) {
// Reached the requested floor; nothing wider left to probe.
return floors;
}
}
// Final probe always covers the full requested window (or unbounded if no floor was given).
floors.push(hardFloor);
return floors;
}
// Convert display level to ClickHouse kinds and statuses
function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?: string[] } {
switch (level) {
case "TRACE":
return { kinds: ["SPAN"] };
case "DEBUG":
return { kinds: ["LOG_DEBUG"] };
case "INFO":
return { kinds: ["LOG_INFO", "LOG_LOG"] };
case "WARN":
return { kinds: ["LOG_WARN"] };
case "ERROR":
return { kinds: ["LOG_ERROR", "SPAN_EVENT"], statuses: ["ERROR"] };
}
}
export class LogsListPresenter extends BasePresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {
super(undefined, replica);
}
public async call(
organizationId: string,
environmentId: string,
{
userId,
projectId,
tasks,
runId,
period,
levels,
search,
from,
to,
cursor,
pageSize = env.LOGS_LIST_DEFAULT_PAGE_SIZE,
defaultPeriod,
retentionLimitDays,
}: LogsListOptions
) {
const time = timeFilterFromTo({
period,
from,
to,
defaultPeriod: defaultPeriod ?? "1h",
});
let effectiveFrom = time.from;
let effectiveTo = time.to;
// Apply retention limit if provided
let wasClampedByRetention = false;
if (retentionLimitDays !== undefined && effectiveFrom) {
const retentionCutoffDate = new Date(Date.now() - retentionLimitDays * 24 * 60 * 60 * 1000);
if (effectiveFrom < retentionCutoffDate) {
effectiveFrom = retentionCutoffDate;
wasClampedByRetention = true;
}
}
const hasFilters =
(tasks !== undefined && tasks.length > 0) ||
(runId !== undefined && runId !== "") ||
(levels !== undefined && levels.length > 0) ||
(search !== undefined && search !== "") ||
!time.isDefault;
const possibleTasksAsync = getTaskIdentifiers(environmentId);
const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
projectId: projectId,
environmentId,
},
orderBy: {
createdAt: "desc",
},
take: 20,
});
const [possibleTasks, bulkActions, displayableEnvironment] = await Promise.all([
possibleTasksAsync,
bulkActionsAsync,
findDisplayableEnvironment(environmentId, userId),
]);
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
// Determine which store to use based on organization configuration
const { store } = await getConfiguredEventRepository(organizationId);
// Throw error if postgres is detected
if (store === EVENT_STORE_TYPES.POSTGRES) {
throw new ServiceValidationError(
"Logs are not available for PostgreSQL event store. Please contact support."
);
}
if (store === EVENT_STORE_TYPES.CLICKHOUSE) {
throw new ServiceValidationError(
"Logs are not available for ClickHouse event store. Please contact support."
);
}
const effectivePageSize = Math.min(pageSize, env.LOGS_LIST_MAX_PAGE_SIZE);
// Only honor a cursor scoped to this org+env; one copied from another scope would shift the
// pagination anchor instead of resetting to the first page.
const parsedCursor = cursor ? decodeCursor(cursor) : null;
const decodedCursor =
parsedCursor &&
parsedCursor.organizationId === organizationId &&
parsedCursor.environmentId === environmentId
? parsedCursor
: null;
// Effective upper bound, always clamped to now so a probe never runs [floor, +inf).
const now = new Date();
const clampedTo = effectiveTo !== undefined ? (effectiveTo > now ? now : effectiveTo) : now;
const searchTerm =
search && search.trim() !== ""
? escapeClickHouseString(search.trim()).toLowerCase()
: undefined;
// Runs the full list query restricted to a single [floor, ceil] window. The recent-first
// probe loop below calls this with progressively wider floors.
const runProbe = (floor: Date | undefined) => {
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder();
// The materialized view excludes events without a trace_id; this guards the legacy tail.
queryBuilder.where("trace_id != ''");
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
if (clampedTo) {
queryBuilder.where("triggered_timestamp <= {triggeredAtEnd: DateTime64(3)}", {
triggeredAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
}
if (floor) {
queryBuilder.where("triggered_timestamp >= {triggeredAtStart: DateTime64(3)}", {
triggeredAtStart: convertDateToClickhouseDateTime(floor),
});
}
// Task filter (applies directly to ClickHouse)
if (tasks && tasks.length > 0) {
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks });
}
// Run ID filter
if (runId && runId !== "") {
queryBuilder.where("run_id = {runId: String}", { runId });
}
// Case-insensitive search in message and attributes
if (searchTerm !== undefined) {
queryBuilder.where(
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
{ searchPattern: `%${searchTerm}%` }
);
}
if (levels && levels.length > 0) {
const conditions: WhereCondition[] = [];
for (let i = 0; i < levels.length; i++) {
const filter = levelToKindsAndStatuses(levels[i]);
if (filter.kinds && filter.kinds.length > 0) {
conditions.push({
clause: `kind IN {kinds_${i}: Array(String)} AND status NOT IN {excluded_statuses: Array(String)}`,
params: {
[`kinds_${i}`]: filter.kinds,
excluded_statuses: ["ERROR", "CANCELLED"],
},
});
}
if (filter.statuses && filter.statuses.length > 0) {
conditions.push({
clause: `status IN {statuses_${i}: Array(String)}`,
params: { [`statuses_${i}`]: filter.statuses },
});
}
}
queryBuilder.whereOr(conditions);
}
// Keyset pagination over the full sort key. ORDER BY is DESC, so the next page is the rows
// that sort after the cursor (strictly less-than). (triggered_timestamp, trace_id) is not
// unique because spans of a trace share both, so span_id is the final tiebreaker; without
// it rows at a tie boundary could be skipped or duplicated across pages.
if (decodedCursor) {
queryBuilder.where(
`(triggered_timestamp < {cursorTriggeredTimestamp: String}
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))`,
{
cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp,
cursorTraceId: decodedCursor.traceId,
cursorSpanId: decodedCursor.spanId,
}
);
}
queryBuilder.orderBy("triggered_timestamp DESC, trace_id DESC, span_id DESC");
// Limit + 1 to check if there are more results
queryBuilder.limit(effectivePageSize + 1);
return queryBuilder.execute();
};
// Page ceiling: the cursor (deeper pages) or the requested upper bound. Widen the lower
// bound only when a recent window doesn't fill the page.
const ceil = decodedCursor
? convertClickhouseDateTime64ToJsDate(decodedCursor.triggeredTimestamp)
: (clampedTo ?? new Date());
const probeFloors = buildProbeFloors(
ceil,
effectiveFrom ?? undefined,
env.LOGS_LIST_RECENT_FIRST_PROBE_DAYS
);
let records: LogsSearchListResult[] = [];
for (const floor of probeFloors) {
const [queryError, probeRecords] = await runProbe(floor);
if (queryError) {
throw queryError;
}
records = probeRecords ?? [];
if (records.length > effectivePageSize) {
// Page is full from this window; older rows can't be in the top page, stop widening.
break;
}
}
const results = records;
const hasMore = results.length > effectivePageSize;
const logs = results.slice(0, effectivePageSize);
// Build next cursor from the last item
let nextCursor: string | undefined;
if (hasMore && logs.length > 0) {
const lastLog = logs[logs.length - 1];
nextCursor = encodeCursor({
v: LOG_CURSOR_VERSION,
organizationId,
environmentId,
triggeredTimestamp: lastLog.triggered_timestamp,
traceId: lastLog.trace_id,
spanId: lastLog.span_id,
});
}
// Transform results
// Use :: as separator since dash conflicts with date format in start_time
const transformedLogs = logs.map((log) => {
let displayMessage = log.message;
// For error logs with status ERROR, try to extract error message from attributes
if (log.status === "ERROR" && log.attributes_text) {
try {
const attributes = JSON.parse(log.attributes_text) as ErrorAttributes;
if (attributes?.error?.message && typeof attributes.error.message === "string") {
displayMessage = attributes.error.message;
}
} catch {
// If attributes parsing fails, use the regular message
}
}
return {
id: `${log.trace_id}::${log.span_id}::${log.run_id}::${log.start_time}`,
runId: log.run_id,
taskIdentifier: log.task_identifier,
startTime: convertClickhouseDateTime64ToJsDate(log.start_time).toISOString(),
triggeredTimestamp: convertClickhouseDateTime64ToJsDate(
log.triggered_timestamp
).toISOString(),
traceId: log.trace_id,
spanId: log.span_id,
parentSpanId: log.parent_span_id || null,
message: displayMessage,
kind: log.kind,
status: log.status,
duration: typeof log.duration === "number" ? log.duration : Number(log.duration),
level: kindToLevel(log.kind, log.status),
};
});
return {
logs: transformedLogs,
pagination: {
next: nextCursor,
previous: undefined, // For now, only support forward pagination
},
possibleTasks,
bulkActions: bulkActions.map((bulkAction) => ({
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
name: bulkAction.name || bulkAction.friendlyId,
})),
filters: {
tasks: tasks || [],
levels: levels || [],
from: effectiveFrom,
to: effectiveTo,
},
hasFilters,
hasAnyLogs: transformedLogs.length > 0,
searchTerm: search,
retention:
retentionLimitDays !== undefined
? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
}
: undefined,
};
}
}
@@ -0,0 +1,142 @@
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import {
getCurrentPlan,
getDefaultEnvironmentLimitFromPlan,
getPlans,
} from "~/services/platform.v3.server";
import { BasePresenter } from "./basePresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
export type ConcurrencyResult = {
canAddConcurrency: boolean;
environments: EnvironmentWithConcurrency[];
extraConcurrency: number;
extraAllocatedConcurrency: number;
extraUnallocatedConcurrency: number;
maxQuota: number;
concurrencyPricing: {
stepSize: number;
centsPerStep: number;
};
};
export type EnvironmentWithConcurrency = {
id: string;
type: RuntimeEnvironmentType;
isBranchableEnvironment: boolean;
branchName: string | null;
parentEnvironmentId: string | null;
maximumConcurrencyLimit: number;
planConcurrencyLimit: number;
};
export class ManageConcurrencyPresenter extends BasePresenter {
public async call({
userId,
projectId,
organizationId,
}: {
userId: string;
projectId: string;
organizationId: string;
}): Promise<ConcurrencyResult> {
// Get plan
const currentPlan = await getCurrentPlan(organizationId);
if (!currentPlan) {
throw new Error("No plan found");
}
const canAddConcurrency =
currentPlan.v3Subscription.plan?.limits.concurrentRuns.canExceed === true;
const environments = await this._replica.runtimeEnvironment.findMany({
select: {
id: true,
projectId: true,
type: true,
branchName: true,
parentEnvironmentId: true,
isBranchableEnvironment: true,
maximumConcurrencyLimit: true,
orgMember: {
select: {
userId: true,
},
},
project: {
select: {
deletedAt: true,
},
},
},
where: {
organizationId,
archivedAt: null,
},
});
const extraConcurrency = currentPlan?.v3Subscription.addOns?.concurrentRuns?.purchased ?? 0;
// Go through all environments and add up extra concurrency above their allowed allocation
let extraAllocatedConcurrency = 0;
const projectEnvironments: EnvironmentWithConcurrency[] = [];
for (const environment of environments) {
// Don't count parent environments
// We don't use isBranchableEnvironment() here as it will include the root dev env
if (environment.type === "PREVIEW" && environment.isBranchableEnvironment) continue;
// Don't count deleted projects
if (environment.project.deletedAt) continue;
const limit = currentPlan
? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan)
: 0;
if (!limit) continue;
// If it's not DEV and they've increased, track that
// You can't spend money to increase DEV concurrency
if (environment.type !== "DEVELOPMENT" && environment.maximumConcurrencyLimit > limit) {
extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit;
}
// We only want to show this project's environments
if (environment.projectId === projectId) {
if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) {
continue;
}
projectEnvironments.push({
id: environment.id,
type: environment.type,
isBranchableEnvironment: environment.isBranchableEnvironment,
branchName: environment.branchName,
parentEnvironmentId: environment.parentEnvironmentId,
maximumConcurrencyLimit: environment.maximumConcurrencyLimit,
planConcurrencyLimit: limit,
});
}
}
const extraAllocated = Math.min(extraConcurrency, extraAllocatedConcurrency);
const plans = await getPlans();
if (!plans) {
throw new Error("Couldn't retrieve add on pricing");
}
return {
canAddConcurrency,
extraConcurrency,
extraAllocatedConcurrency: extraAllocated,
extraUnallocatedConcurrency: extraConcurrency - extraAllocated,
maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0,
environments: sortEnvironments(projectEnvironments, [
"PRODUCTION",
"STAGING",
"PREVIEW",
"DEVELOPMENT",
]),
concurrencyPricing: plans.addOnPricing.concurrency,
};
}
}
@@ -0,0 +1,133 @@
import { BasePresenter } from "./basePresenter.server";
import { type QueryScope } from "~/services/queryService.server";
import { getLimit } from "~/services/platform.v3.server";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import { builtInDashboard } from "./BuiltInDashboards.server";
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
export type MetricFilters = {
/** Org, project, environment */
scope: QueryScope;
/** Time filter settings */
filterPeriod: string | null;
filterFrom: Date | null;
filterTo: Date | null;
/** Tasks */
taskIdentifiers?: string[];
/** Queues */
queues?: string[];
/** Tags */
tags?: string[];
};
export const LayoutItem = z.object({
i: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
minH: z.number().optional(),
maxH: z.number().optional(),
});
export type LayoutItem = z.infer<typeof LayoutItem>;
export const Widget = z.object({
title: z.string(),
query: z.string().default(""),
display: QueryWidgetConfig,
});
export type Widget = z.infer<typeof Widget>;
export const DashboardLayout = z.discriminatedUnion("version", [
z.object({
version: z.literal("1"),
layout: z.array(LayoutItem),
widgets: z.record(Widget),
}),
]);
export type DashboardLayout = z.infer<typeof DashboardLayout>;
export type CustomDashboard = {
friendlyId: string;
title: string;
layout: DashboardLayout;
defaultPeriod: string;
};
export type BuiltInDashboardFilter =
| "tasks"
| "queues"
| "models"
| "prompts"
| "operations"
| "providers";
export type BuiltInDashboard = {
key: string;
title: string;
layout: DashboardLayout;
/** Which filters to show in the toolbar. Defaults to ["tasks", "queues"] if not specified. */
filters?: BuiltInDashboardFilter[];
};
/** Returns the dashboard layout */
export class MetricDashboardPresenter extends BasePresenter {
public async customDashboard({
friendlyId,
organizationId,
}: {
friendlyId: string;
organizationId: string;
}): Promise<CustomDashboard> {
const dashboard = await this._replica.metricsDashboard.findFirst({
where: { friendlyId, organizationId },
});
if (!dashboard) {
throw new Error("No dashboard found");
}
const layout = this.#getLayout(dashboard.layout);
const defaultPeriod = await getDashboardDefaultPeriod(organizationId);
return {
friendlyId: dashboard.friendlyId,
title: dashboard.title,
layout,
defaultPeriod,
};
}
public async builtInDashboard({ organizationId, key }: { organizationId: string; key: string }) {
const defaultPeriod = await getDashboardDefaultPeriod(organizationId);
const dashboard = builtInDashboard(key);
return {
...dashboard,
defaultPeriod,
};
}
#getLayout(layoutData: string): DashboardLayout {
const json = JSON.parse(layoutData);
const parsedLayout = DashboardLayout.safeParse(json);
if (!parsedLayout.success) {
throw fromZodError(parsedLayout.error);
}
return parsedLayout.data;
}
}
/** Dashboard-specific default period (1 day), capped to the org's max query period */
async function getDashboardDefaultPeriod(organizationId: string): Promise<string> {
const idealDefaultPeriodDays = 1;
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
if (maxQueryPeriod < idealDefaultPeriodDays) {
return `${maxQueryPeriod}d`;
}
return `${idealDefaultPeriodDays}d`;
}
@@ -0,0 +1,834 @@
import type { ClickHouse } from "@internal/clickhouse";
import { modelCatalog } from "@internal/llm-model-catalog";
import type { PrismaClientOrTransaction } from "~/db.server";
import { BasePresenter } from "./basePresenter.server";
import { z } from "zod";
/** Format a Date for ClickHouse DateTime64 string params. */
function formatDateForCH(date: Date): string {
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
}
// --- Helpers ---
/** Infer provider from model name when not stored in the DB. */
function inferProvider(modelName: string): string {
const lower = modelName.toLowerCase();
// OpenAI
if (
/^(gpt-|o[1-9]|chatgpt|davinci|babbage|curie|ada|text-embedding|text-davinci|text-ada|text-babbage|text-curie|ft:)/.test(
lower
)
)
return "openai";
// Anthropic
if (lower.startsWith("claude-")) return "anthropic";
// Google
if (
/^(gemini-|palm-|text-bison|chat-bison|code-bison|codechat-bison|text-unicorn|textembedding-gecko)/.test(
lower
)
)
return "google";
// Meta
if (/^(llama|code-llama|codellama)/.test(lower)) return "meta";
// Mistral
if (/^(mistral|mixtral|codestral|pixtral|ministral)/.test(lower)) return "mistral";
// xAI
if (lower.startsWith("grok")) return "xai";
// DeepSeek
if (lower.startsWith("deepseek")) return "deepseek";
// Cohere
if (/^(command|embed-|rerank-)/.test(lower)) return "cohere";
// AI21
if (/^(jamba|j2-)/.test(lower)) return "ai21";
// Amazon
if (/^(amazon\.|titan)/.test(lower)) return "amazon";
// Qwen (Alibaba)
if (lower.startsWith("qwen")) return "qwen";
// Perplexity
if (/^(pplx-|sonar-)/.test(lower)) return "perplexity";
// Nous
if (lower.startsWith("nous-")) return "nous";
// Provider prefix format: "provider/model" (e.g. "openai/gpt-4o")
if (lower.includes("/")) {
return lower.split("/")[0];
}
return "unknown";
}
/** Format a model as provider:name (e.g. "openai:gpt-5"). */
export function formatModelId(provider: string, modelName: string): string {
return `${provider}:${modelName}`;
}
/**
* Hardcoded provider display priority (most relevant first). Providers not in
* this list fall back to alphabetical order after the listed ones. Within a
* provider, models are always sorted by release date (newest first).
*/
const PROVIDER_IMPORTANCE = ["anthropic", "openai", "google", "xai", "meta", "mistral", "deepseek"];
function providerRank(provider: string): number {
const index = PROVIDER_IMPORTANCE.indexOf(provider);
return index === -1 ? PROVIDER_IMPORTANCE.length : index;
}
/**
* Pick a sparkline bucket size (in seconds) for a given range so the rendered
* sparkline stays a readable ~24-52 bars. Tuned for the small inline charts in
* the "Your models" list — coarser than the full-size dashboard charts.
*/
function sparklineBucketSeconds(rangeMs: number): number {
const MIN = 60;
const HOUR = 3600;
const DAY = 86400;
const ms = (s: number) => s * 1000;
if (rangeMs <= ms(HOUR)) return 2 * MIN;
if (rangeMs <= ms(3 * HOUR)) return 5 * MIN;
if (rangeMs <= ms(6 * HOUR)) return 15 * MIN;
if (rangeMs <= ms(DAY)) return HOUR;
if (rangeMs <= ms(3 * DAY)) return 2 * HOUR;
if (rangeMs <= ms(7 * DAY)) return 6 * HOUR;
if (rangeMs <= ms(14 * DAY)) return 12 * HOUR;
if (rangeMs <= ms(30 * DAY)) return DAY;
if (rangeMs <= ms(90 * DAY)) return 3 * DAY;
return 7 * DAY;
}
/**
* Generate the ordered bucket-start keys for [from, to] at the given interval,
* as epoch seconds to match ClickHouse's
* `toUnixTimestamp(toStartOfInterval(col, INTERVAL n SECOND))` — timezone-independent
* (a raw DateTime string would depend on the ClickHouse server timezone).
*/
function sparklineBucketKeys(from: Date, to: Date, intervalSeconds: number): number[] {
const intervalMs = intervalSeconds * 1000;
const start = Math.floor(from.getTime() / intervalMs) * intervalMs;
const end = Math.floor(to.getTime() / intervalMs) * intervalMs;
const keys: number[] = [];
for (let t = start; t <= end; t += intervalMs) {
keys.push(t / 1000);
}
return keys;
}
// --- Types ---
export type ModelCatalogItem = {
friendlyId: string;
modelName: string;
/** Always resolved — from DB, inferred from name, or "unknown". */
provider: string;
/** Display identifier in provider:name format (e.g. "openai:gpt-5"). */
displayId: string;
description: string | null;
contextWindow: number | null;
maxOutputTokens: number | null;
/** Combined capabilities (from DB) and boolean feature flags (from catalog) as slug strings. */
features: string[];
inputPrice: number | null;
outputPrice: number | null;
/** When the model was publicly released (from startDate on LlmModel). */
releaseDate: string | null;
/** Dated variants of this model (only populated on base models). */
variants: ModelVariant[];
};
export type ModelVariant = {
friendlyId: string;
modelName: string;
displayId: string;
releaseDate: string | null;
};
export type ModelCatalogGroup = {
provider: string;
models: ModelCatalogItem[];
};
export type ModelDetail = ModelCatalogItem & {
matchPattern: string;
source: string;
pricingTiers: Array<{
name: string;
isDefault: boolean;
prices: Record<string, number>;
}>;
};
function buildFeatures(
capabilities: string[],
catalogEntry:
| {
supportsStructuredOutput: boolean;
supportsParallelToolCalls: boolean;
supportsStreamingToolCalls: boolean;
}
| undefined
): string[] {
const features = new Set(capabilities);
if (catalogEntry?.supportsStructuredOutput) features.add("structured_output");
if (catalogEntry?.supportsParallelToolCalls) features.add("parallel_tool_calls");
if (catalogEntry?.supportsStreamingToolCalls) features.add("streaming_tool_calls");
return Array.from(features);
}
export type ModelMetricsPoint = {
minute: string;
callCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCost: number;
ttfcP50: number;
ttfcP90: number;
ttfcP95: number;
ttfcP99: number;
tpsP50: number;
tpsP90: number;
tpsP95: number;
tpsP99: number;
durationP50: number;
durationP90: number;
durationP95: number;
durationP99: number;
};
export type UserModelMetrics = {
totalCalls: number;
totalCost: number;
totalInputTokens: number;
totalOutputTokens: number;
avgTtfc: number;
avgTps: number;
taskBreakdown: Array<{
taskIdentifier: string;
calls: number;
cost: number;
}>;
};
export type ModelComparisonItem = {
responseModel: string;
genAiSystem: string;
callCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCost: number;
ttfcP50: number;
ttfcP90: number;
tpsP50: number;
tpsP90: number;
};
export type PopularModel = {
responseModel: string;
genAiSystem: string;
callCount: number;
totalCost: number;
ttfcP50: number;
};
/** A model with usage in a specific project/environment (the "Your models" list). */
export type ProjectModelUsageItem = {
responseModel: string;
genAiSystem: string;
calls: number;
totalCost: number;
totalTokens: number;
avgTtfc: number;
avgTps: number;
/** Input tokens (used as the denominator for the cache read rate). */
inputTokens: number;
/** Input tokens served from the provider's prompt cache. */
cachedReadTokens: number;
/** Actual (discounted) cost of those cached read tokens. */
cachedReadCost: number;
};
// --- ClickHouse schemas for user metrics ---
const UserMetricsSummaryRow = z.object({
total_calls: z.coerce.number(),
total_cost: z.coerce.number(),
total_input_tokens: z.coerce.number(),
total_output_tokens: z.coerce.number(),
avg_ttfc: z.coerce.number(),
avg_tps: z.coerce.number(),
});
const UserTaskBreakdownRow = z.object({
task_identifier: z.string(),
calls: z.coerce.number(),
cost: z.coerce.number(),
});
const ProjectModelUsageRow = z.object({
response_model: z.string(),
gen_ai_system: z.string(),
calls: z.coerce.number(),
total_cost: z.coerce.number(),
total_tokens: z.coerce.number(),
avg_ttfc: z.coerce.number(),
avg_tps: z.coerce.number(),
input_tokens: z.coerce.number(),
cached_read_tokens: z.coerce.number(),
cached_read_cost: z.coerce.number(),
});
const ModelSparklineRow = z.object({
response_model: z.string(),
bucket: z.coerce.number(),
val: z.coerce.number(),
});
// --- Presenter ---
export class ModelRegistryPresenter extends BasePresenter {
private readonly clickhouse: ClickHouse;
constructor(clickhouse: ClickHouse, replica?: PrismaClientOrTransaction) {
super(undefined, replica);
this.clickhouse = clickhouse;
}
/** List all visible global models with pricing, grouped by provider. */
async getModelCatalog(): Promise<ModelCatalogGroup[]> {
const models = await this._replica.llmModel.findMany({
where: {
projectId: null,
isHidden: false,
},
include: {
pricingTiers: {
where: { isDefault: true },
include: { prices: true },
take: 1,
},
},
orderBy: { modelName: "asc" },
});
type CatalogItemWithBase = ModelCatalogItem & { _baseModelName: string | null };
const items: CatalogItemWithBase[] = models.map((m) => {
const defaultTier = m.pricingTiers[0];
const prices = defaultTier?.prices ?? [];
const inputPrice = prices.find((p) => p.usageType === "input");
const outputPrice = prices.find((p) => p.usageType === "output");
const provider = m.provider ?? inferProvider(m.modelName);
const catalogEntry = modelCatalog[m.modelName];
return {
friendlyId: m.friendlyId,
modelName: m.modelName,
provider,
displayId: formatModelId(provider, m.modelName),
description: m.description,
contextWindow: m.contextWindow,
maxOutputTokens: m.maxOutputTokens,
features: buildFeatures(m.capabilities, catalogEntry),
inputPrice: inputPrice ? Number(inputPrice.price) : null,
outputPrice: outputPrice ? Number(outputPrice.price) : null,
releaseDate: m.startDate ? m.startDate.toISOString().split("T")[0] : null,
variants: [],
_baseModelName: m.baseModelName,
};
});
// Normalize version dots for grouping: "3.5" → "3-5", "4.1" → "4-1"
const normalizeForGrouping = (name: string) => name.replace(/(\d)\.(\d)/g, "$1-$2");
// Group variants by their normalized base model name
const variantGroups = new Map<string, CatalogItemWithBase[]>();
for (const item of items) {
const groupKey = normalizeForGrouping(item._baseModelName ?? item.modelName);
const group = variantGroups.get(groupKey) ?? [];
group.push(item);
variantGroups.set(groupKey, group);
}
// For each group, pick the best representative as the "card" model
// and nest the rest as variants
const baseModels: ModelCatalogItem[] = [];
for (const [_groupKey, group] of variantGroups) {
if (group.length === 1) {
// Standalone model, no variants
baseModels.push(group[0]);
continue;
}
// Pick representative: prefer the actual base model (no _baseModelName),
// then "-latest" variant, then the newest by release date
let representative =
group.find((m) => !m._baseModelName) ??
group.find((m) => m.modelName.endsWith("-latest")) ??
group.sort((a, b) => {
if (!a.releaseDate && !b.releaseDate) return 0;
if (!a.releaseDate) return 1;
if (!b.releaseDate) return -1;
return b.releaseDate.localeCompare(a.releaseDate);
})[0];
// Nest the others as variants, sorted newest first
const others = group
.filter((m) => m !== representative)
.sort((a, b) => {
if (!a.releaseDate && !b.releaseDate) return a.modelName.localeCompare(b.modelName);
if (!a.releaseDate) return 1;
if (!b.releaseDate) return -1;
return b.releaseDate.localeCompare(a.releaseDate);
});
representative.variants = others.map((m) => ({
friendlyId: m.friendlyId,
modelName: m.modelName,
displayId: m.displayId,
releaseDate: m.releaseDate,
}));
baseModels.push(representative);
}
// Group by provider, sort models within each group by release date (newest first)
const groups = new Map<string, ModelCatalogItem[]>();
for (const item of baseModels) {
const group = groups.get(item.provider) ?? [];
group.push(item);
groups.set(item.provider, group);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
const rankA = providerRank(a);
const rankB = providerRank(b);
if (rankA !== rankB) return rankA - rankB;
return a.localeCompare(b);
})
.map(([provider, models]) => ({
provider,
models: models.sort((a, b) => {
if (!a.releaseDate && !b.releaseDate) return a.modelName.localeCompare(b.modelName);
if (!a.releaseDate) return 1;
if (!b.releaseDate) return -1;
return b.releaseDate.localeCompare(a.releaseDate);
}),
}));
}
/** Get a single model with full pricing details. */
async getModelDetail(friendlyId: string): Promise<ModelDetail | null> {
const model = await this._replica.llmModel.findFirst({
where: { friendlyId },
include: {
pricingTiers: {
include: { prices: true },
orderBy: { priority: "asc" },
},
},
});
if (!model) return null;
const defaultTier = model.pricingTiers.find((t) => t.isDefault) ?? model.pricingTiers[0];
const defaultPrices = defaultTier?.prices ?? [];
const inputPrice = defaultPrices.find((p) => p.usageType === "input");
const outputPrice = defaultPrices.find((p) => p.usageType === "output");
const provider = model.provider ?? inferProvider(model.modelName);
const catalogEntry = modelCatalog[model.modelName];
return {
friendlyId: model.friendlyId,
modelName: model.modelName,
provider,
displayId: formatModelId(provider, model.modelName),
description: model.description,
contextWindow: model.contextWindow,
maxOutputTokens: model.maxOutputTokens,
features: buildFeatures(model.capabilities, catalogEntry),
inputPrice: inputPrice ? Number(inputPrice.price) : null,
outputPrice: outputPrice ? Number(outputPrice.price) : null,
releaseDate: model.startDate ? model.startDate.toISOString().split("T")[0] : null,
variants: [],
matchPattern: model.matchPattern,
source: model.source,
pricingTiers: model.pricingTiers.map((t) => ({
name: t.name,
isDefault: t.isDefault,
prices: Object.fromEntries(t.prices.map((p) => [p.usageType, Number(p.price)])),
})),
};
}
/** Get global aggregate metrics for a model (no tenant info). */
async getGlobalMetrics(
responseModel: string,
startTime: Date,
endTime: Date
): Promise<ModelMetricsPoint[]> {
const [error, rows] = await this.clickhouse.llmModelAggregates.globalMetrics
.setParams({
responseModel,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
})
.execute();
if (error || !rows) return [];
return rows.map((r) => ({
minute: r.minute,
callCount: r.call_count,
totalInputTokens: r.total_input_tokens,
totalOutputTokens: r.total_output_tokens,
totalCost: r.total_cost,
ttfcP50: r.ttfc_p50,
ttfcP90: r.ttfc_p90,
ttfcP95: r.ttfc_p95,
ttfcP99: r.ttfc_p99,
tpsP50: r.tps_p50,
tpsP90: r.tps_p90,
tpsP95: 0,
tpsP99: 0,
durationP50: r.duration_p50,
durationP90: r.duration_p90,
durationP95: 0,
durationP99: 0,
}));
}
/** Get per-project usage metrics for a model. */
async getUserMetrics(
responseModel: string,
projectId: string,
environmentId: string,
startTime: Date,
endTime: Date
): Promise<UserModelMetrics> {
const summaryQuery = this.clickhouse.reader.query({
name: "modelRegistryUserSummary",
query: `
SELECT
count() AS total_calls,
sum(total_cost) AS total_cost,
sum(input_tokens) AS total_input_tokens,
sum(output_tokens) AS total_output_tokens,
round(avg(ms_to_first_chunk), 1) AS avg_ttfc,
round(avg(tokens_per_second), 1) AS avg_tps
FROM trigger_dev.llm_metrics_v1
WHERE response_model = {responseModel: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
`,
params: z.object({
responseModel: z.string(),
projectId: z.string(),
environmentId: z.string(),
startTime: z.string(),
endTime: z.string(),
}),
schema: UserMetricsSummaryRow,
});
const taskQuery = this.clickhouse.reader.query({
name: "modelRegistryUserTasks",
query: `
SELECT
task_identifier,
count() AS calls,
sum(total_cost) AS cost
FROM trigger_dev.llm_metrics_v1
WHERE response_model = {responseModel: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
GROUP BY task_identifier
ORDER BY cost DESC
LIMIT 20
`,
params: z.object({
responseModel: z.string(),
projectId: z.string(),
environmentId: z.string(),
startTime: z.string(),
endTime: z.string(),
}),
schema: UserTaskBreakdownRow,
});
const queryParams = {
responseModel,
projectId,
environmentId,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
};
const [summaryResult, taskResult] = await Promise.all([
summaryQuery(queryParams),
taskQuery(queryParams),
]);
const [summaryError, summaryRows] = summaryResult;
const [taskError, taskRows] = taskResult;
const defaultSummary = {
total_calls: 0,
total_cost: 0,
total_input_tokens: 0,
total_output_tokens: 0,
avg_ttfc: 0,
avg_tps: 0,
};
const summary = !summaryError && summaryRows?.[0] ? summaryRows[0] : defaultSummary;
return {
totalCalls: summary.total_calls,
totalCost: summary.total_cost,
totalInputTokens: summary.total_input_tokens,
totalOutputTokens: summary.total_output_tokens,
avgTtfc: summary.avg_ttfc,
avgTps: summary.avg_tps,
taskBreakdown:
!taskError && taskRows
? taskRows.map((r) => ({
taskIdentifier: r.task_identifier,
calls: r.calls,
cost: r.cost,
}))
: [],
};
}
/** Get comparison data for 2-4 models. */
async getModelComparison(
responseModels: string[],
startTime: Date,
endTime: Date
): Promise<ModelComparisonItem[]> {
const [error, rows] = await this.clickhouse.llmModelAggregates.comparison
.setParams({
responseModels,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
})
.execute();
if (error || !rows) return [];
return rows.map((r) => ({
responseModel: r.response_model,
genAiSystem: r.gen_ai_system,
callCount: r.call_count,
totalInputTokens: r.total_input_tokens,
totalOutputTokens: r.total_output_tokens,
totalCost: r.total_cost,
ttfcP50: r.ttfc_p50,
ttfcP90: r.ttfc_p90,
tpsP50: r.tps_p50,
tpsP90: r.tps_p90,
}));
}
/** Get the most popular models by call count. */
async getPopularModels(
startTime: Date,
endTime: Date,
limit: number = 20
): Promise<PopularModel[]> {
const [error, rows] = await this.clickhouse.llmModelAggregates.popular
.setParams({
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
limit,
})
.execute();
if (error || !rows) return [];
return rows.map((r) => ({
responseModel: r.response_model,
genAiSystem: r.gen_ai_system,
callCount: r.call_count,
totalCost: r.total_cost,
ttfcP50: r.ttfc_p50,
}));
}
/**
* Models that had usage in a specific project/environment over the window,
* with aggregate metrics. This is the tenant-scoped "Your models" list (as
* opposed to the cross-tenant getPopularModels).
*/
async getProjectModelUsage(
projectId: string,
environmentId: string,
startTime: Date,
endTime: Date
): Promise<ProjectModelUsageItem[]> {
const queryFn = this.clickhouse.reader.query({
name: "modelRegistryProjectUsage",
query: `
SELECT
response_model,
any(gen_ai_system) AS gen_ai_system,
count() AS calls,
sum(total_cost) AS total_cost,
sum(total_tokens) AS total_tokens,
round(avg(ms_to_first_chunk), 1) AS avg_ttfc,
round(avg(tokens_per_second), 1) AS avg_tps,
sum(input_tokens) AS input_tokens,
sum(usage_details['input_cached_tokens']) AS cached_read_tokens,
sum(cost_details['input_cached_tokens']) AS cached_read_cost
FROM trigger_dev.llm_metrics_v1
WHERE project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
AND response_model != ''
GROUP BY response_model
ORDER BY calls DESC
LIMIT 100
`,
params: z.object({
projectId: z.string(),
environmentId: z.string(),
startTime: z.string(),
endTime: z.string(),
}),
schema: ProjectModelUsageRow,
});
const [error, rows] = await queryFn({
projectId,
environmentId,
startTime: formatDateForCH(startTime),
endTime: formatDateForCH(endTime),
});
if (error || !rows) return [];
return rows.map((r) => ({
responseModel: r.response_model,
genAiSystem: r.gen_ai_system,
calls: r.calls,
totalCost: r.total_cost,
totalTokens: r.total_tokens,
avgTtfc: r.avg_ttfc,
avgTps: r.avg_tps,
inputTokens: r.input_tokens,
cachedReadTokens: r.cached_read_tokens,
cachedReadCost: r.cached_read_cost,
}));
}
/**
* Call-count and total-token sparklines per response_model over [from, to],
* matching the window the "Your models" charts and table use. The bucket size
* adapts to the range (see sparklineBucketSeconds) so a sparkline stays a
* readable ~24-52 bars regardless of the selected period. Zero-filled.
*/
async getModelUsageSparklines(
projectId: string,
environmentId: string,
responseModels: string[],
from: Date,
to: Date
): Promise<{
calls: Record<string, number[]>;
tokens: Record<string, number[]>;
bucketIntervalMs: number;
bucketStartMs: number;
}> {
const intervalSeconds = sparklineBucketSeconds(to.getTime() - from.getTime());
const intervalMs = intervalSeconds * 1000;
// Epoch-aligned start of the first bucket, matching sparklineBucketKeys and
// ClickHouse toStartOfInterval. Returned so the sparkline tooltip can label
// each bar with its true time rather than assuming hourly buckets.
const bucketStartMs = Math.floor(from.getTime() / intervalMs) * intervalMs;
if (responseModels.length === 0) {
return { calls: {}, tokens: {}, bucketIntervalMs: intervalMs, bucketStartMs };
}
const bucketKeys = sparklineBucketKeys(from, to, intervalSeconds);
// intervalSeconds is a server-derived integer from a fixed ladder, so it's
// safe to inline. Epoch-aligned SECOND buckets match the JS keys above.
const buildQuery = (valueExpr: string, name: string) =>
this.clickhouse.reader.query({
name,
query: `
SELECT
response_model,
toUnixTimestamp(toStartOfInterval(start_time, INTERVAL ${intervalSeconds} SECOND)) AS bucket,
${valueExpr} AS val
FROM trigger_dev.llm_metrics_v1
WHERE project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND response_model IN {responseModels: Array(String)}
AND start_time >= {startTime: String}
AND start_time <= {endTime: String}
GROUP BY response_model, bucket
ORDER BY response_model, bucket
`,
params: z.object({
projectId: z.string(),
environmentId: z.string(),
responseModels: z.array(z.string()),
startTime: z.string(),
endTime: z.string(),
}),
schema: ModelSparklineRow,
});
const queryParams = {
projectId,
environmentId,
responseModels,
startTime: formatDateForCH(from),
endTime: formatDateForCH(to),
};
const [callsResult, tokensResult] = await Promise.all([
buildQuery("count()", "modelCallSparklines")(queryParams),
buildQuery("sum(total_tokens)", "modelTokenSparklines")(queryParams),
]);
return {
calls: this.#buildSparklineMap(callsResult, responseModels, bucketKeys),
tokens: this.#buildSparklineMap(tokensResult, responseModels, bucketKeys),
bucketIntervalMs: intervalMs,
bucketStartMs,
};
}
/** Convert a sparkline query result to a zero-filled bucket map. */
#buildSparklineMap(
queryResult: [Error, null] | [null, { response_model: string; bucket: number; val: number }[]],
keys: string[],
bucketKeys: number[]
): Record<string, number[]> {
const [error, rows] = queryResult;
if (error || !rows) return {};
const rowMap = new Map<string, number>();
for (const row of rows) {
rowMap.set(`${row.response_model}|${row.bucket}`, row.val);
}
const result: Record<string, number[]> = {};
for (const key of keys) {
result[key] = bucketKeys.map((b) => rowMap.get(`${key}|${b}`) ?? 0);
}
return result;
}
}
@@ -0,0 +1,159 @@
import {
type AuthenticatableIntegration,
OrgIntegrationRepository,
} from "~/models/orgIntegration.server";
import { BasePresenter } from "./basePresenter.server";
import { type WebClient } from "@slack/web-api";
import { tryCatch } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
export class NewAlertChannelPresenter extends BasePresenter {
public async call(projectId: string) {
const project = await this._prisma.project.findFirstOrThrow({
where: {
id: projectId,
},
});
// Find the latest Slack integration
const slackIntegration = await this._prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
deletedAt: null,
},
orderBy: {
createdAt: "desc",
},
include: {
tokenReference: true,
},
});
// If there is a slack integration, then we need to get a list of Slack Channels
if (slackIntegration) {
const [error, channels] = await tryCatch(getSlackChannelsForToken(slackIntegration));
if (error) {
if (isSlackError(error) && error.data.error === "token_revoked") {
return {
slack: {
status: "TOKEN_REVOKED" as const,
},
};
}
if (isSlackError(error) && error.data.error === "token_expired") {
return {
slack: {
status: "TOKEN_EXPIRED" as const,
},
};
}
logger.error("Failed fetching Slack channels for creating alerts", {
error,
slackIntegrationId: slackIntegration.id,
});
return {
slack: {
status: "FAILED_FETCHING_CHANNELS" as const,
},
};
}
return {
slack: {
status: "READY" as const,
channels: channels ?? [],
integrationId: slackIntegration.id,
},
};
}
if (OrgIntegrationRepository.isSlackSupported) {
return {
slack: {
status: "NOT_CONFIGURED" as const,
},
};
}
return {
slack: {
status: "NOT_AVAILABLE" as const,
},
};
}
}
async function getSlackChannelsForToken(integration: AuthenticatableIntegration) {
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(integration);
const channels = await getAllSlackConversations(client);
return (channels ?? [])
.filter((channel) => !channel.is_archived)
.filter((channel) => channel.is_channel)
.filter((channel) => channel.num_members)
.sort((a, b) => a.name!.localeCompare(b.name!));
}
type Channels = Awaited<ReturnType<WebClient["conversations"]["list"]>>["channels"];
async function getAllSlackConversations(client: WebClient) {
let nextCursor: string | undefined = undefined;
let channels: Channels = [];
try {
do {
// The `tryCatch` util runs into a type error due to self referencing.
// So we fall back to a good old try/catch block here.
const response = await client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: true,
cursor: nextCursor,
limit: 999,
});
channels = channels.concat(response.channels ?? []);
nextCursor = response.response_metadata?.next_cursor;
} while (nextCursor);
} catch (error) {
if (error && isSlackError(error) && error.data.error === "ratelimited") {
logger.warn("Rate limiting issue occurred while fetching Slack channels", {
error,
});
// This is a workaround to the current way we handle Slack channels for creating alerts.
// For workspaces with a lot of channels (>10000) we might hit the rate limit for this slack endpoint,
// as multiple requests are needed to fetch all channels.
// We use the largest allowed page size of 999 to reduce the chance of hitting the rate limit.
// This is mainly due to workspaces with a large number of archived channels,
// which this slack endpoint unfortunately filters out only after fetching the page of channels without applying any filters first.
// https://api.slack.com/methods/conversations.list#markdown
// We expect most workspaces not to run into this issue, but if they do, we at least return some channels.
// In the future, we might revisit the way we handle Slack channels and cache them on our side to support
// proper searching. Until then, we track occurrences of this issue using a metric.
return channels;
}
throw error;
}
return channels;
}
function isSlackError(obj: unknown): obj is { data: { error: string } } {
return Boolean(
typeof obj === "object" &&
obj !== null &&
"data" in obj &&
typeof obj.data === "object" &&
obj.data !== null &&
"error" in obj.data &&
typeof obj.data.error === "string"
);
}
@@ -0,0 +1,366 @@
import { type ClickHouse } from "@internal/clickhouse";
import type { MachinePresetName } from "@trigger.dev/core/v3";
import { RunAnnotations } from "@trigger.dev/core/v3/schemas";
import {
type PrismaClient,
type PrismaClientOrTransaction,
type TaskRunStatus,
} from "@trigger.dev/database";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { env } from "~/env.server";
import {
createCache,
createLRUMemoryStore,
DefaultStatefulContext,
Namespace,
} from "@internal/cache";
import { RedisCacheStore } from "~/services/unkey/redisCacheStore.server";
import { singleton } from "~/utils/singleton";
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
import { machinePresetFromRun } from "~/v3/machinePresets.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
// Positive-only cache: only envs known to have runs are stored (empty envs are re-checked),
// so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis.
const runsExistCache = singleton("runsExistCache", () => {
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(5000, "runs-has-runs-cache");
const redis = new RedisCacheStore({
name: "runs-has-runs",
connection: {
keyPrefix: "tr:cache:runs-has-runs",
port: env.CACHE_REDIS_PORT,
host: env.CACHE_REDIS_HOST,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
return createCache({
hasRuns: new Namespace<boolean>(ctx, {
stores: [memory, redis],
fresh: env.RUN_LIST_HAS_RUNS_CACHE_FRESH_MS,
stale: env.RUN_LIST_HAS_RUNS_CACHE_STALE_MS,
}),
});
});
export type RunListOptions = {
userId?: string;
projectId: string;
//filters
tasks?: string[];
versions?: string[];
statuses?: TaskRunStatus[];
tags?: string[];
scheduleId?: string;
period?: string;
bulkId?: string;
from?: number;
to?: number;
isTest?: boolean;
rootOnly?: boolean;
batchId?: string;
runId?: string[];
queues?: string[];
regions?: string[];
machines?: MachinePresetName[];
errorId?: string;
sources?: string[];
//pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
// Run the empty-state "has any run ever" probe. Only the runs list consumes it.
includeHasAnyRuns?: boolean;
};
const DEFAULT_PAGE_SIZE = 25;
export type NextRunList = Awaited<ReturnType<NextRunListPresenter["call"]>>;
export type NextRunListItem = NextRunList["runs"][0];
export type NextRunListAppliedFilters = NextRunList["filters"];
export class NextRunListPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse,
private readonly readThroughDeps?: {
// The new run-ops client + the legacy run-ops read replica (never the legacy writer).
// Omitted => single-DB / self-host: both default to `replica` (passthrough).
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
// Resolved boot constant from isSplitEnabled(). When false/absent:
// list hydrate runs passthrough and the empty-state probe is one plain findFirst.
splitEnabled?: boolean;
}
) {}
// Empty-state existence probe, served from ClickHouse (same connection as the runs
// list) so it no longer scans TaskRun in Postgres. SWR-cached to spare ClickHouse;
// RUN_LIST_HAS_RUNS_LOOKBACK_DAYS bounds the prove-absence partition scan.
async #anyRunExistsInEnv(
runsRepository: RunsRepository,
organizationId: string,
projectId: string,
environmentId: string
): Promise<boolean> {
const lookbackDays = env.RUN_LIST_HAS_RUNS_LOOKBACK_DAYS;
const createdAtLowerBoundMs =
lookbackDays > 0 ? Date.now() - lookbackDays * 24 * 60 * 60 * 1000 : undefined;
const result = await runsExistCache.hasRuns.swr(environmentId, async () => {
const exists = await runsRepository.runExistsInEnvironment({
organizationId,
projectId,
environmentId,
createdAtLowerBoundMs,
});
// undefined (not false) so swr does NOT cache the empty result — re-check until a run exists.
return exists ? true : undefined;
});
return result.val ?? false;
}
public async call(
organizationId: string,
environmentId: string,
{
userId,
projectId,
tasks,
versions,
statuses,
tags,
scheduleId,
period,
bulkId,
isTest,
rootOnly,
batchId,
runId,
queues,
regions,
machines,
errorId,
sources,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
includeHasAnyRuns = false,
}: RunListOptions
) {
//get the time values from the raw values (including a default period)
const time = timeFilters({
period,
from,
to,
});
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
(sources !== undefined && sources.length > 0) ||
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
hasStatusFilters ||
(bulkId !== undefined && bulkId !== "") ||
(scheduleId !== undefined && scheduleId !== "") ||
(tags !== undefined && tags.length > 0) ||
batchId !== undefined ||
(runId !== undefined && runId.length > 0) ||
(queues !== undefined && queues.length > 0) ||
(regions !== undefined && regions.length > 0) ||
(machines !== undefined && machines.length > 0) ||
(errorId !== undefined && errorId !== "") ||
typeof isTest === "boolean" ||
rootOnly === true ||
!time.isDefault;
const possibleTasksAsync = getTaskIdentifiers(environmentId);
const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
projectId: projectId,
environmentId,
},
orderBy: {
createdAt: "desc",
},
take: 20,
});
const [possibleTasks, bulkActions, displayableEnvironment] = await Promise.all([
possibleTasksAsync,
bulkActionsAsync,
findDisplayableEnvironment(environmentId, userId),
]);
// If the bulk action isn't in the most recent ones, add it separately
if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) {
const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
friendlyId: bulkId,
projectId,
environmentId,
},
});
if (selectedBulkAction) {
bulkActions.push(selectedBulkAction);
}
}
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
const runsRepository = new RunsRepository({
clickhouse: this.clickhouse,
prisma: this.replica as PrismaClient,
readThrough: this.readThroughDeps
? {
newClient: this.readThroughDeps.newClient ?? this.replica,
legacyReplica: this.readThroughDeps.legacyReplica ?? this.replica,
splitEnabled: this.readThroughDeps.splitEnabled ?? false,
}
: undefined,
});
function clampToNow(date: Date): Date {
const now = new Date();
return date > now ? now : date;
}
const { runs, pagination } = await runsRepository.listRuns({
organizationId,
environmentId,
projectId,
tasks,
versions,
statuses,
tags,
scheduleId,
period,
from: time.from ? time.from.getTime() : undefined,
to: time.to ? clampToNow(time.to).getTime() : undefined,
isTest,
rootOnly,
batchId,
runId,
bulkId,
queues,
regions,
machines,
errorId,
taskKinds: sources,
page: {
size: pageSize,
cursor,
direction,
},
});
let hasAnyRuns = runs.length > 0;
if (!hasAnyRuns && includeHasAnyRuns) {
hasAnyRuns = await this.#anyRunExistsInEnv(
runsRepository,
organizationId,
projectId,
environmentId
);
}
return {
runs: runs.map((run) => {
const hasFinished = isFinalRunStatus(run.status);
const startedAt = run.startedAt ?? run.lockedAt;
return {
id: run.id,
number: 1,
friendlyId: run.friendlyId,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
startedAt: startedAt ? startedAt.toISOString() : undefined,
delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined,
hasFinished,
finishedAt: hasFinished
? (run.completedAt?.toISOString() ?? run.updatedAt.toISOString())
: undefined,
isTest: run.isTest,
status: run.status,
version: run.taskVersion,
taskIdentifier: run.taskIdentifier,
spanId: run.spanId,
isReplayable: true,
isCancellable: isCancellableRunStatus(run.status),
isPending: isPendingRunStatus(run.status),
environment: displayableEnvironment,
idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined,
ttl: run.ttl ? run.ttl : undefined,
expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
usageDurationMs: Number(run.usageDurationMs),
tags: run.runTags ? run.runTags.sort((a, b) => a.localeCompare(b)) : [],
depth: run.depth,
rootTaskRunId: run.rootTaskRunId,
metadata: run.metadata,
metadataType: run.metadataType,
machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined,
queue: {
name: run.queue.replace("task/", ""),
type: run.queue.startsWith("task/") ? "task" : "custom",
},
region: regionForDisplay(run.region, run.workerQueue),
taskKind: RunAnnotations.safeParse(run.annotations).data?.taskKind ?? "STANDARD",
};
}),
pagination: {
next: pagination.nextCursor ?? undefined,
previous: pagination.previousCursor ?? undefined,
},
possibleTasks,
bulkActions: bulkActions.map((bulkAction) => ({
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
name: bulkAction.name || bulkAction.friendlyId,
})),
filters: {
tasks: tasks || [],
versions: versions || [],
statuses: statuses || [],
from: time.from,
to: time.to,
},
hasFilters,
hasAnyRuns,
};
}
}
@@ -0,0 +1,151 @@
import type {
RuntimeEnvironmentType,
TaskRunStatus,
TaskTriggerSource,
} from "@trigger.dev/database";
import { $replica } from "~/db.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
export type PlaygroundAgent = {
slug: string;
filePath: string;
triggerSource: TaskTriggerSource;
config: unknown;
payloadSchema: unknown;
};
export type PlaygroundConversation = {
id: string;
chatId: string;
title: string;
agentSlug: string;
runFriendlyId: string | null;
runStatus: TaskRunStatus | null;
clientData: unknown;
messages: unknown;
lastEventId: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
export class PlaygroundPresenter {
async listAgents({
environmentId,
environmentType,
}: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
}): Promise<PlaygroundAgent[]> {
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: environmentId, type: environmentType },
$replica
);
if (!currentWorker) return [];
return $replica.backgroundWorkerTask.findMany({
where: {
workerId: currentWorker.id,
triggerSource: "AGENT",
},
select: {
slug: true,
filePath: true,
triggerSource: true,
config: true,
payloadSchema: true,
},
orderBy: { slug: "asc" },
});
}
async getAgent({
environmentId,
environmentType,
agentSlug,
}: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
agentSlug: string;
}): Promise<PlaygroundAgent | null> {
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: environmentId, type: environmentType },
$replica
);
if (!currentWorker) return null;
return $replica.backgroundWorkerTask.findFirst({
where: {
workerId: currentWorker.id,
triggerSource: "AGENT",
slug: agentSlug,
},
select: {
slug: true,
filePath: true,
triggerSource: true,
config: true,
payloadSchema: true,
},
});
}
async getRecentConversations({
environmentId,
agentSlug,
userId,
limit = 10,
}: {
environmentId: string;
agentSlug: string;
userId: string;
limit?: number;
}): Promise<PlaygroundConversation[]> {
const conversations = await $replica.playgroundConversation.findMany({
where: {
runtimeEnvironmentId: environmentId,
agentSlug,
userId,
},
select: {
id: true,
chatId: true,
title: true,
agentSlug: true,
clientData: true,
messages: true,
lastEventId: true,
createdAt: true,
updatedAt: true,
run: {
select: {
friendlyId: true,
status: true,
},
},
},
orderBy: { updatedAt: "desc" },
take: limit,
});
return conversations.map((c) => ({
id: c.id,
chatId: c.chatId,
title: c.title,
agentSlug: c.agentSlug,
runFriendlyId: c.run?.friendlyId ?? null,
runStatus: c.run?.status ?? null,
clientData: c.clientData,
messages: c.messages,
lastEventId: c.lastEventId,
isActive: c.run?.status ? !isFinalRunStatus(c.run.status) : false,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
}));
}
}
export const playgroundPresenter = new PlaygroundPresenter();
@@ -0,0 +1,434 @@
import type { ClickHouse } from "@internal/clickhouse";
import type { PrismaClientOrTransaction } from "~/db.server";
import { BasePresenter } from "./basePresenter.server";
import { z } from "zod";
const GenerationRowSchema = z.object({
run_id: z.string(),
span_id: z.string(),
operation_id: z.string(),
task_identifier: z.string(),
response_model: z.string(),
prompt_version: z.coerce.number(),
input_tokens: z.coerce.number(),
output_tokens: z.coerce.number(),
total_cost: z.coerce.number(),
duration_ms: z.coerce.number(),
started_at: z.string(),
});
export type GenerationRow = {
run_id: string;
span_id: string;
operation_id: string;
task_identifier: string;
response_model: string;
prompt_version: number;
input_tokens: number;
output_tokens: number;
total_cost: number;
duration_ms: number;
start_time: string;
};
export type GenerationsPagination = {
next?: string;
};
export class PromptPresenter extends BasePresenter {
private readonly clickhouse: ClickHouse;
constructor(clickhouse: ClickHouse, replica?: PrismaClientOrTransaction) {
super(undefined, replica);
this.clickhouse = clickhouse;
}
async listPrompts(projectId: string, environmentId: string) {
const prompts = await this._replica.prompt.findMany({
where: {
projectId,
runtimeEnvironmentId: environmentId,
archivedAt: null,
},
include: {
versions: {
where: {
labels: { hasSome: ["current", "override"] },
},
select: {
version: true,
labels: true,
model: true,
},
},
},
orderBy: { updatedAt: "desc" },
});
return prompts.map((p) => {
const currentVersion = p.versions.find((v) => v.labels.includes("current"));
const overrideVersion = p.versions.find((v) => v.labels.includes("override"));
const hasOverride = !!overrideVersion;
// Effective model: override > current version > prompt default
const effectiveModel = overrideVersion?.model ?? currentVersion?.model ?? p.defaultModel;
return {
id: p.id,
friendlyId: p.friendlyId,
slug: p.slug,
description: p.description,
tags: p.tags,
defaultModel: effectiveModel,
currentVersion: currentVersion ? { version: currentVersion.version } : null,
overrideVersion: overrideVersion ? { version: overrideVersion.version } : null,
hasOverride,
updatedAt: p.updatedAt,
};
});
}
async getUsageSparklines(
environmentId: string,
promptSlugs: string[]
): Promise<Record<string, number[]>> {
if (promptSlugs.length === 0) return {};
const queryFn = this.clickhouse.reader.query({
name: "promptUsageSparklines",
query: `SELECT
prompt_slug,
toStartOfHour(start_time) AS bucket,
count() AS cnt
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND prompt_slug IN {promptSlugs: Array(String)}
AND start_time >= now() - INTERVAL 24 HOUR
GROUP BY prompt_slug, bucket
ORDER BY prompt_slug, bucket`,
params: z.object({
environmentId: z.string(),
promptSlugs: z.array(z.string()),
}),
schema: z.object({
prompt_slug: z.string(),
bucket: z.string(),
cnt: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({ environmentId, promptSlugs });
if (error) {
console.error("Prompt usage sparkline query failed:", error);
return {};
}
// Build a map of slug -> 24 hourly buckets (use UTC to match ClickHouse)
const now = new Date();
const startHour = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours() - 23,
0,
0,
0
)
);
const bucketKeys: string[] = [];
for (let i = 0; i < 24; i++) {
const h = new Date(startHour.getTime() + i * 3600_000);
// Format to match ClickHouse's toStartOfHour output: "YYYY-MM-DD HH:MM:SS"
bucketKeys.push(h.toISOString().slice(0, 13).replace("T", " ") + ":00:00");
}
// Index rows by slug+bucket for fast lookup
const rowMap = new Map<string, number>();
for (const row of rows) {
rowMap.set(`${row.prompt_slug}|${row.bucket}`, row.cnt);
}
const result: Record<string, number[]> = {};
for (const slug of promptSlugs) {
result[slug] = bucketKeys.map((key) => rowMap.get(`${slug}|${key}`) ?? 0);
}
return result;
}
async resolveVersion(promptId: string, options?: { version?: number; label?: string }) {
if (options?.version != null) {
return this._replica.promptVersion.findUnique({
where: {
promptId_version: {
promptId,
version: options.version,
},
},
});
}
// Check for override first — dashboard edits take precedence
const override = await this._replica.promptVersion.findFirst({
where: {
promptId,
labels: { has: "override" },
},
orderBy: { version: "desc" },
});
if (override) {
return override;
}
const label = options?.label ?? "current";
return this._replica.promptVersion.findFirst({
where: {
promptId,
labels: { has: label },
},
orderBy: { version: "desc" },
});
}
async listVersions(promptId: string, limit: number = 50) {
return this._replica.promptVersion.findMany({
where: { promptId },
orderBy: { version: "desc" },
take: limit,
select: {
id: true,
version: true,
labels: true,
source: true,
model: true,
textContent: true,
commitMessage: true,
contentHash: true,
createdAt: true,
},
});
}
async listGenerations(options: {
environmentId: string;
promptSlug: string;
promptVersions?: number[];
startTime: Date;
endTime: Date;
cursor?: string;
pageSize?: number;
responseModels?: string[];
operations?: string[];
providers?: string[];
}): Promise<{ generations: GenerationRow[]; pagination: GenerationsPagination }> {
const pageSize = options.pageSize ?? 25;
const decodedCursor = options.cursor ? decodeCursor(options.cursor) : null;
const cursorClause = decodedCursor
? `AND (start_time < parseDateTimeBestEffort({cursorStartTime: String})
OR (start_time = parseDateTimeBestEffort({cursorStartTime: String})
AND span_id < {cursorSpanId: String}))`
: "";
const versionClause = options.promptVersions?.length
? `AND prompt_version IN {promptVersions: Array(UInt32)}`
: "";
const modelClause = options.responseModels?.length
? `AND response_model IN {responseModels: Array(String)}`
: "";
const operationClause = options.operations?.length
? `AND operation_id IN {operations: Array(String)}`
: "";
const providerClause = options.providers?.length
? `AND gen_ai_system IN {providers: Array(String)}`
: "";
// Build a unique query name based on which optional filters are active
const filterKey = [
decodedCursor ? "c" : "",
options.promptVersions?.length ? "v" : "",
options.responseModels?.length ? "m" : "",
options.operations?.length ? "o" : "",
options.providers?.length ? "p" : "",
].join("");
const queryFn = this.clickhouse.reader.query({
name: `promptGenerationsList${filterKey}`,
query: `SELECT
run_id, span_id, operation_id, task_identifier, response_model,
prompt_version,
input_tokens, output_tokens, total_cost,
duration / 1000000 AS duration_ms,
formatDateTime(start_time, '%Y-%m-%d %H:%i:%S') AS started_at
FROM trigger_dev.llm_metrics_v1
WHERE environment_id = {environmentId: String}
AND prompt_slug = {promptSlug: String}
${versionClause}
AND start_time >= parseDateTimeBestEffort({startTime: String})
AND start_time <= parseDateTimeBestEffort({endTime: String})
${cursorClause}
${modelClause}
${operationClause}
${providerClause}
ORDER BY start_time DESC, span_id DESC
LIMIT {fetchLimit: UInt32}`,
params: z.object({
environmentId: z.string(),
promptSlug: z.string(),
startTime: z.string(),
endTime: z.string(),
fetchLimit: z.number(),
...(decodedCursor
? {
cursorStartTime: z.string(),
cursorSpanId: z.string(),
}
: {}),
...(options.promptVersions?.length ? { promptVersions: z.array(z.number()) } : {}),
...(options.responseModels?.length ? { responseModels: z.array(z.string()) } : {}),
...(options.operations?.length ? { operations: z.array(z.string()) } : {}),
...(options.providers?.length ? { providers: z.array(z.string()) } : {}),
}),
schema: GenerationRowSchema,
});
const queryParams: Record<string, unknown> = {
environmentId: options.environmentId,
promptSlug: options.promptSlug,
startTime: options.startTime.toISOString(),
endTime: options.endTime.toISOString(),
fetchLimit: pageSize + 1,
};
if (decodedCursor) {
queryParams.cursorStartTime = decodedCursor.startTime;
queryParams.cursorSpanId = decodedCursor.spanId;
}
if (options.promptVersions?.length) {
queryParams.promptVersions = options.promptVersions;
}
if (options.responseModels?.length) {
queryParams.responseModels = options.responseModels;
}
if (options.operations?.length) {
queryParams.operations = options.operations;
}
if (options.providers?.length) {
queryParams.providers = options.providers;
}
const [error, rows] = await queryFn(queryParams as any);
if (error) {
console.error("Prompt generations query failed:", error);
return { generations: [], pagination: {} };
}
const hasMore = rows.length > pageSize;
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
const generations: GenerationRow[] = pageRows.map((r) => ({
...r,
start_time: r.started_at,
}));
const pagination: GenerationsPagination = {};
if (hasMore) {
const lastRow = pageRows[pageRows.length - 1];
pagination.next = encodeCursor(lastRow.started_at, lastRow.span_id);
}
return { generations, pagination };
}
async getDistinctPromptSlugs(
organizationId: string,
projectId: string,
environmentId: string
): Promise<string[]> {
const queryFn = this.clickhouse.reader.query({
name: "getDistinctPromptSlugs",
query: `SELECT DISTINCT prompt_slug FROM trigger_dev.llm_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} AND environment_id = {environmentId: String} AND prompt_slug != '' ORDER BY prompt_slug`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
}),
schema: z.object({ prompt_slug: z.string() }),
});
const [error, rows] = await queryFn({ organizationId, projectId, environmentId });
if (error) {
return [];
}
return rows.map((r) => r.prompt_slug);
}
async getDistinctOperations(
organizationId: string,
projectId: string,
environmentId: string
): Promise<string[]> {
const queryFn = this.clickhouse.reader.query({
name: "getDistinctOperations",
query: `SELECT DISTINCT operation_id FROM trigger_dev.llm_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} AND environment_id = {environmentId: String} AND operation_id != '' ORDER BY operation_id`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
}),
schema: z.object({ operation_id: z.string() }),
});
const [error, rows] = await queryFn({ organizationId, projectId, environmentId });
if (error) {
return [];
}
return rows.map((r) => r.operation_id);
}
async getDistinctProviders(
organizationId: string,
projectId: string,
environmentId: string
): Promise<string[]> {
const queryFn = this.clickhouse.reader.query({
name: "getDistinctProviders",
query: `SELECT DISTINCT gen_ai_system FROM trigger_dev.llm_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} AND environment_id = {environmentId: String} AND gen_ai_system != '' ORDER BY gen_ai_system`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
}),
schema: z.object({ gen_ai_system: z.string() }),
});
const [error, rows] = await queryFn({ organizationId, projectId, environmentId });
if (error) {
return [];
}
return rows.map((r) => r.gen_ai_system);
}
}
function encodeCursor(startTime: string, spanId: string): string {
return Buffer.from(JSON.stringify({ s: startTime, i: spanId })).toString("base64");
}
function decodeCursor(cursor: string): { startTime: string; spanId: string } | null {
try {
const parsed = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8")) as Record<
string,
unknown
>;
if (typeof parsed.s === "string" && typeof parsed.i === "string") {
return { startTime: parsed.s, spanId: parsed.i };
}
return null;
} catch {
return null;
}
}
@@ -0,0 +1,57 @@
import { defaultQuery } from "~/v3/querySchemas";
import { BasePresenter } from "./basePresenter.server";
import type { QueryScope } from "~/services/queryService.server";
export type QueryHistoryItem = {
id: string;
query: string;
scope: QueryScope;
createdAt: Date;
userName: string | null;
/** AI-generated title summarizing the query */
title: string | null;
/** Time filter settings */
filterPeriod: string | null;
filterFrom: Date | null;
filterTo: Date | null;
};
export class QueryPresenter extends BasePresenter {
public async call({ organizationId }: { organizationId: string }) {
const history = await this._replica.customerQuery.findMany({
where: { organizationId },
orderBy: { createdAt: "desc" },
take: 20,
select: {
id: true,
query: true,
scope: true,
title: true,
createdAt: true,
filterPeriod: true,
filterFrom: true,
filterTo: true,
user: {
select: { name: true, displayName: true },
},
},
});
return {
defaultQuery,
history: history.map(
(q): QueryHistoryItem => ({
id: q.id,
query: q.query,
scope: q.scope.toLowerCase() as QueryScope,
createdAt: q.createdAt,
userName: q.user?.displayName ?? q.user?.name ?? null,
title: q.title,
filterPeriod: q.filterPeriod,
filterFrom: q.filterFrom,
filterTo: q.filterTo,
})
),
};
}
}
@@ -0,0 +1,241 @@
import type { RunEngine } from "@internal/run-engine";
import type { Prisma } from "@trigger.dev/database";
import { TaskQueueType } from "@trigger.dev/database";
import { type PrismaClientOrTransaction } from "~/db.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
import { toQueueItem } from "./QueueRetrievePresenter.server";
type QueueListEngine = Pick<RunEngine, "lengthOfQueues" | "currentConcurrencyOfQueues">;
export const QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE = 25;
const MAX_ITEMS_PER_PAGE = 100;
const typeToDBQueueType: Record<"task" | "custom", TaskQueueType> = {
task: TaskQueueType.VIRTUAL,
custom: TaskQueueType.NAMED,
};
const queueListSelect = {
friendlyId: true,
name: true,
orderableName: true,
concurrencyLimit: true,
concurrencyLimitBase: true,
concurrencyLimitOverriddenAt: true,
concurrencyLimitOverriddenBy: true,
type: true,
paused: true,
} satisfies Prisma.TaskQueueSelect;
function buildQueueListWhere(
environmentId: string,
query: string | undefined,
type: "task" | "custom" | undefined
): Prisma.TaskQueueWhereInput {
const trimmedQuery = query?.trim();
return {
runtimeEnvironmentId: environmentId,
version: "V2",
name: trimmedQuery
? {
contains: trimmedQuery,
mode: "insensitive",
}
: undefined,
type: type ? typeToDBQueueType[type] : undefined,
};
}
export class QueueListPresenter extends BasePresenter {
private readonly perPage: number;
private readonly engineClient: QueueListEngine;
constructor(
perPage: number = QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE,
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
engineClient: QueueListEngine = engine
) {
super(prismaClient, replicaClient);
this.perPage = Math.min(perPage, MAX_ITEMS_PER_PAGE);
this.engineClient = engineClient;
}
public async call({
environment,
query,
page,
type,
}: {
environment: AuthenticatedEnvironment;
query?: string;
page: number;
perPage?: number;
type?: "task" | "custom";
}) {
const hasFilters = Boolean(query?.trim()) || type !== undefined;
const engineVersion = await determineEngineVersion({ environment });
if (engineVersion === "V1") {
const totalQueues = await this._replica.taskQueue.count({
where: buildQueueListWhere(environment.id, query, type),
});
if (totalQueues === 0) {
const oldQueue = await this._replica.taskQueue.findFirst({
where: {
runtimeEnvironmentId: environment.id,
version: "V1",
},
});
if (oldQueue) {
return {
success: false as const,
code: "engine-version",
totalQueues: 1,
hasFilters,
};
}
}
return {
success: false as const,
code: "engine-version",
totalQueues,
hasFilters,
};
}
if (hasFilters) {
const { queues, hasMore } = await this.getFilteredQueues(environment, query, page, type);
return {
success: true as const,
queues,
pagination: {
mode: "filtered" as const,
currentPage: page,
hasMore,
},
hasFilters,
};
}
const totalQueues = await this._replica.taskQueue.count({
where: buildQueueListWhere(environment.id, query, type),
});
return {
success: true as const,
queues: await this.getUnfilteredQueues(environment, page, type),
pagination: {
mode: "unfiltered" as const,
currentPage: page,
totalPages: Math.ceil(totalQueues / this.perPage),
count: totalQueues,
},
totalQueues,
hasFilters,
};
}
private async getFilteredQueues(
environment: AuthenticatedEnvironment,
query: string | undefined,
page: number,
type: "task" | "custom" | undefined
) {
const queues = await this._replica.taskQueue.findMany({
where: buildQueueListWhere(environment.id, query, type),
select: queueListSelect,
orderBy: {
orderableName: "asc",
},
skip: (page - 1) * this.perPage,
take: this.perPage + 1,
});
const hasMore = queues.length > this.perPage;
return {
queues: await this.enrichQueues(environment, queues.slice(0, this.perPage)),
hasMore,
};
}
private async getUnfilteredQueues(
environment: AuthenticatedEnvironment,
page: number,
type: "task" | "custom" | undefined
) {
const queues = await this._replica.taskQueue.findMany({
where: buildQueueListWhere(environment.id, undefined, type),
select: queueListSelect,
orderBy: {
orderableName: "asc",
},
skip: (page - 1) * this.perPage,
take: this.perPage,
});
return this.enrichQueues(environment, queues);
}
private async enrichQueues(
environment: AuthenticatedEnvironment,
queues: {
friendlyId: string;
name: string;
orderableName: string | null;
concurrencyLimit: number | null;
concurrencyLimitBase: number | null;
concurrencyLimitOverriddenAt: Date | null;
concurrencyLimitOverriddenBy: string | null;
type: TaskQueueType;
paused: boolean;
}[]
) {
const [queuedByQueue, runningByQueue] = await Promise.all([
this.engineClient.lengthOfQueues(
environment,
queues.map((q) => q.name)
),
this.engineClient.currentConcurrencyOfQueues(
environment,
queues.map((q) => q.name)
),
]);
// Manually "join" the overridden users because there is no way to implement the relationship
// in prisma without adding a foreign key constraint
const overriddenByIds = queues.map((q) => q.concurrencyLimitOverriddenBy).filter(Boolean);
const overriddenByUsers = await this._replica.user.findMany({
where: {
id: { in: overriddenByIds },
},
});
const overriddenByMap = new Map(overriddenByUsers.map((u) => [u.id, u]));
return queues.map((queue) =>
toQueueItem({
friendlyId: queue.friendlyId,
name: queue.name,
type: queue.type,
running: runningByQueue[queue.name] ?? 0,
queued: queuedByQueue[queue.name] ?? 0,
concurrencyLimit: queue.concurrencyLimit ?? null,
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy
? (overriddenByMap.get(queue.concurrencyLimitOverriddenBy) ?? null)
: null,
paused: queue.paused,
})
);
}
}
@@ -0,0 +1,178 @@
import { assertExhaustive } from "@trigger.dev/core";
import { type Prettify, type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
import {
type PrismaClientOrTransaction,
type TaskQueue,
type User,
type TaskQueueType,
} from "@trigger.dev/database";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
export type FoundQueue = Prettify<
Omit<TaskQueue, "concurrencyLimitOverriddenBy"> & {
concurrencyLimitOverriddenBy?: User | null;
}
>;
/**
* Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService
*/
export async function getQueue(
prismaClient: PrismaClientOrTransaction,
environment: AuthenticatedEnvironment,
queue: RetrieveQueueParam
) {
if (typeof queue === "string") {
return joinQueueWithUser(
prismaClient,
await prismaClient.taskQueue.findFirst({
where: {
friendlyId: queue,
runtimeEnvironmentId: environment.id,
},
})
);
}
const queueName =
queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name;
return joinQueueWithUser(
prismaClient,
await prismaClient.taskQueue.findFirst({
where: {
name: queueName,
runtimeEnvironmentId: environment.id,
},
})
);
}
async function joinQueueWithUser(
prismaClient: PrismaClientOrTransaction,
queue?: TaskQueue | null
): Promise<FoundQueue | undefined> {
if (!queue) return undefined;
if (!queue.concurrencyLimitOverriddenBy) {
return {
...queue,
concurrencyLimitOverriddenBy: undefined,
};
}
const user = await prismaClient.user.findFirst({
where: { id: queue.concurrencyLimitOverriddenBy },
});
return {
...queue,
concurrencyLimitOverriddenBy: user,
};
}
export class QueueRetrievePresenter extends BasePresenter {
public async call({
environment,
queueInput,
}: {
environment: AuthenticatedEnvironment;
queueInput: RetrieveQueueParam;
}) {
//check the engine is the correct version
const engineVersion = await determineEngineVersion({ environment });
if (engineVersion === "V1") {
return {
success: false as const,
code: "engine-version",
};
}
const queue = await getQueue(this._replica, environment, queueInput);
if (!queue) {
return {
success: false as const,
code: "queue-not-found",
};
}
const results = await Promise.all([
engine.lengthOfQueues(environment, [queue.name]),
engine.currentConcurrencyOfQueues(environment, [queue.name]),
]);
// Transform queues to include running and queued counts
return {
success: true as const,
queue: toQueueItem({
friendlyId: queue.friendlyId,
name: queue.name,
type: queue.type,
running: results[1]?.[queue.name] ?? 0,
queued: results[0]?.[queue.name] ?? 0,
concurrencyLimit: queue.concurrencyLimit ?? null,
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null,
paused: queue.paused,
}),
};
}
}
export function queueTypeFromType(type: TaskQueueType) {
switch (type) {
case "NAMED":
return "custom" as const;
case "VIRTUAL":
return "task" as const;
default:
assertExhaustive(type);
}
}
/**
* Converts raw queue data into a standardized QueueItem format
* @param data Raw queue data containing required queue properties
* @returns A validated QueueItem object
*/
export function toQueueItem(data: {
friendlyId: string;
name: string;
type: TaskQueueType;
running: number;
queued: number;
concurrencyLimit: number | null;
concurrencyLimitBase: number | null;
concurrencyLimitOverriddenAt: Date | null;
concurrencyLimitOverriddenBy: User | null;
paused: boolean;
}): QueueItem & { releaseConcurrencyOnWaitpoint: boolean } {
return {
id: data.friendlyId,
//remove the task/ prefix if it exists
name: data.name.replace(/^task\//, ""),
type: queueTypeFromType(data.type),
running: data.running,
queued: data.queued,
paused: data.paused,
concurrencyLimit: data.concurrencyLimit,
concurrency: {
current: data.concurrencyLimit,
base: data.concurrencyLimitBase,
override: data.concurrencyLimitOverriddenAt ? data.concurrencyLimit : null,
overriddenBy: toQueueConcurrencyOverriddenBy(data.concurrencyLimitOverriddenBy),
overriddenAt: data.concurrencyLimitOverriddenAt,
},
// TODO: This needs to be removed but keeping this here for now to avoid breaking existing clients
releaseConcurrencyOnWaitpoint: true,
};
}
function toQueueConcurrencyOverriddenBy(user: User | null) {
if (!user) return null;
return user.displayName ?? user.name ?? null;
}
@@ -0,0 +1,178 @@
import { type WorkloadType } from "@trigger.dev/database";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
import { defaultVisibilityFilter, resolveComputeAccess } from "~/v3/regionAccess.server";
import { BasePresenter } from "./basePresenter.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
export type Region = {
id: string;
name: string;
masterQueue: string;
description?: string;
cloudProvider?: string;
location?: string;
staticIPs?: string | null;
isDefault: boolean;
isHidden: boolean;
workloadType: WorkloadType;
};
export class RegionsPresenter extends BasePresenter {
public async call({
userId,
projectSlug,
isAdmin = false,
}: {
userId: User["id"];
projectSlug: Project["slug"];
isAdmin?: boolean;
}) {
const project = await this._replica.project.findFirst({
select: {
id: true,
organizationId: true,
defaultWorkerGroupId: true,
allowedWorkerQueues: true,
organization: {
select: { featureFlags: true },
},
},
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
throw new Error("Project not found");
}
const getFlag = makeFlag(this._replica);
const defaultWorkerInstanceGroupId = await getFlag({
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
});
if (!defaultWorkerInstanceGroupId) {
throw new Error("Default worker instance group not found");
}
const hasComputeAccess = await resolveComputeAccess(
this._replica,
project.organization.featureFlags
);
const visibleRegions = await this._replica.workerInstanceGroup.findMany({
select: {
id: true,
name: true,
masterQueue: true,
description: true,
cloudProvider: true,
location: true,
staticIPs: true,
hidden: true,
workloadType: true,
},
where: isAdmin
? undefined
: // Hide hidden unless they're allowed to use them
project.allowedWorkerQueues.length > 0
? {
masterQueue: { in: project.allowedWorkerQueues },
}
: defaultVisibilityFilter(hasComputeAccess),
orderBy: {
name: "asc",
},
});
const regions: Region[] = visibleRegions.map((region) => ({
id: region.id,
name: region.name,
masterQueue: region.masterQueue,
description: region.description ?? undefined,
cloudProvider: region.cloudProvider ?? undefined,
location: region.location ?? undefined,
staticIPs: region.staticIPs ?? undefined,
isDefault: region.id === defaultWorkerInstanceGroupId,
isHidden: region.hidden,
workloadType: region.workloadType,
}));
if (project.defaultWorkerGroupId) {
const defaultWorkerGroup = await this._replica.workerInstanceGroup.findFirst({
select: {
id: true,
name: true,
masterQueue: true,
description: true,
cloudProvider: true,
location: true,
staticIPs: true,
hidden: true,
workloadType: true,
},
where: { id: project.defaultWorkerGroupId },
});
if (defaultWorkerGroup) {
// Unset the default region
const defaultRegion = regions.find((region) => region.isDefault);
if (defaultRegion) {
defaultRegion.isDefault = false;
}
regions.push({
id: defaultWorkerGroup.id,
name: defaultWorkerGroup.name,
masterQueue: defaultWorkerGroup.masterQueue,
description: defaultWorkerGroup.description ?? undefined,
cloudProvider: defaultWorkerGroup.cloudProvider ?? undefined,
location: defaultWorkerGroup.location ?? undefined,
staticIPs: defaultWorkerGroup.staticIPs ?? undefined,
isDefault: true,
isHidden: defaultWorkerGroup.hidden,
workloadType: defaultWorkerGroup.workloadType,
});
}
}
// Default first
const sorted = regions.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return a.name.localeCompare(b.name);
});
// Remove later duplicates
let unique = sorted.filter((region, index, self) => {
const firstIndex = self.findIndex((t) => t.id === region.id);
return index === firstIndex;
});
// Don't show static IPs for free users
// Even if they had the IPs they wouldn't work, but this makes it less confusing
const currentPlan = await getCurrentPlan(project.organizationId);
const isPaying = currentPlan?.v3Subscription.isPaying === true;
if (!isPaying) {
unique = unique.map((region) => ({
...region,
staticIPs: region.staticIPs ? null : undefined,
}));
}
return {
regions: unique.sort((a, b) => a.name.localeCompare(b.name)),
isPaying,
};
}
}
@@ -0,0 +1,377 @@
import { millisecondsToNanoseconds, RunAnnotations } from "@trigger.dev/core/v3";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import { prisma, type PrismaClient } from "~/db.server";
import { logger } from "~/services/logger.server";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { getUsername } from "~/utils/username";
import type { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
import { env } from "~/env.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
export type RunEvent = NonNullable<Result["trace"]>["events"][0];
export class RunEnvironmentMismatchError extends Error {
constructor(message: string) {
super(message);
this.name = "RunEnvironmentMismatchError";
}
}
// Thrown by `call()` when the run isn't in PG. The route loader catches
// this and falls back to the mollifier buffer via `tryMollifiedRunFallback`.
// Using a typed error (rather than Prisma's `findFirstOrThrow` exception)
// keeps the buffered case off the PrismaClient error path — that path
// emits a `PrismaClient error` log every time it fires, which on the
// run-detail page polls becomes per-tick log spam and Sentry noise for
// any run that legitimately lives in the buffer.
export class RunNotInPgError extends Error {
constructor(public readonly runFriendlyId: string) {
super(`Run ${runFriendlyId} not in PG`);
this.name = "RunNotInPgError";
}
}
export class RunPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
environmentSlug,
runFriendlyId,
showDeletedLogs,
showDebug,
}: {
userId: string;
projectSlug: string;
environmentSlug: string;
runFriendlyId: string;
showDeletedLogs: boolean;
showDebug: boolean;
}) {
// `findFirst` + explicit null check (not `findFirstOrThrow`) because
// a missing PG row is the *expected* path for buffered runs — the
// route catches `RunNotInPgError` and falls back to the synthesised
// buffer view. `findFirstOrThrow` would log a `PrismaClient error`
// every tick of the page poll, masking real DB issues with synthetic
// not-found noise.
//
// No explicit client arg: the store reads off its own replica and routes by
// residency once a RoutingRunStore is injected. Pinning this.#prismaClient
// would override that routing. (The user.findFirst admin check below stays on
// the control-plane client.)
// Run-ops read keyed by friendlyId only — routes to the owning DB by residency.
// The project-scope + membership auth is a control-plane concern resolved
// separately below; joining project/organization here is a cross-DB join that
// returns nothing once the run lives in the run-ops DB.
const run = await runStore.findRun(
{
friendlyId: runFriendlyId,
},
{
select: {
projectId: true,
id: true,
createdAt: true,
taskEventStore: true,
taskIdentifier: true,
number: true,
traceId: true,
spanId: true,
parentSpanId: true,
friendlyId: true,
status: true,
startedAt: true,
completedAt: true,
logsDeletedAt: true,
annotations: true,
rootTaskRun: {
select: {
friendlyId: true,
spanId: true,
createdAt: true,
},
},
parentTaskRun: {
select: {
friendlyId: true,
spanId: true,
createdAt: true,
},
},
runtimeEnvironmentId: true,
},
}
);
if (!run) {
throw new RunNotInPgError(runFriendlyId);
}
// Project-scope + membership auth is control-plane only — verify the run's
// project matches the requested slug and the user is a member, keyed by the
// run's projectId. A miss is treated as not-found (mirrors the old scoped where).
const authorizedProject = await this.#prismaClient.project.findFirst({
where: {
id: run.projectId,
slug: projectSlug,
organization: { members: { some: { userId } } },
},
select: { id: true },
});
if (!authorizedProject) {
throw new RunNotInPgError(runFriendlyId);
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(
run.runtimeEnvironmentId
);
if (!environment) {
// An unresolvable control-plane env means the run can't be presented from PG;
// mirror the not-found path the route already handles (mollifier buffer fallback).
throw new RunNotInPgError(runFriendlyId);
}
if (environmentSlug !== environment.slug) {
throw new RunEnvironmentMismatchError(
`Run ${runFriendlyId} is not in environment ${environmentSlug}`
);
}
const showLogs = showDeletedLogs || !run.logsDeletedAt;
const runData = {
id: run.id,
number: run.number,
friendlyId: run.friendlyId,
traceId: run.traceId,
spanId: run.spanId,
status: run.status,
isFinished: isFinalRunStatus(run.status),
startedAt: run.startedAt,
completedAt: run.completedAt,
logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt,
rootTaskRun: run.rootTaskRun,
parentTaskRun: run.parentTaskRun,
environment: {
id: environment.id,
organizationId: environment.organizationId,
type: environment.type,
slug: environment.slug,
userId: environment.orgMember?.user?.id,
userName: getUsername(environment.orgMember?.user),
},
};
if (!showLogs) {
return {
run: runData,
trace: undefined,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
};
}
const repository = await getEventRepositoryForStore(
run.taskEventStore,
environment.organizationId
);
const traceTimeBounds = {
startCreatedAt: run.rootTaskRun?.createdAt ?? run.createdAt,
endCreatedAt: run.completedAt ?? undefined,
};
// Fast path: full trace summary. Slow path: subtree fetch when the anchor
// span fell past the row cap (large traces ordered by start_time ASC).
let traceSummary = await repository.getTraceSummary(
getTaskEventStoreTableForRun(run),
environment.id,
run.traceId,
traceTimeBounds.startCreatedAt,
traceTimeBounds.endCreatedAt,
{ includeDebugLogs: showDebug }
);
let isTruncated = traceSummary?.isTruncated ?? false;
const hasAnchorSpan = traceSummary?.spans.some((span) => span.id === run.spanId) ?? false;
if (traceSummary && !hasAnchorSpan) {
logger.warn("Trace summary missing anchor span, falling back to subtree fetch", {
runId: run.friendlyId,
spanId: run.spanId,
traceId: run.traceId,
spanCount: traceSummary.spans.length,
});
const subtreeSummary = await repository.getTraceSubtreeSummary(
getTaskEventStoreTableForRun(run),
environment.id,
run.traceId,
run.spanId,
traceTimeBounds.startCreatedAt,
traceTimeBounds.endCreatedAt,
{ includeDebugLogs: showDebug }
);
if (subtreeSummary) {
traceSummary = subtreeSummary;
isTruncated = subtreeSummary.isTruncated ?? false;
}
}
if (!traceSummary) {
const spanSummary: SpanSummary = {
id: run.spanId,
parentId: run.parentSpanId ?? undefined,
runId: run.friendlyId,
data: {
message: run.taskIdentifier,
style: { icon: "task", variant: "primary" },
events: [],
startTime: run.createdAt,
duration: 0,
isError:
run.status === "COMPLETED_WITH_ERRORS" ||
run.status === "CRASHED" ||
run.status === "EXPIRED" ||
run.status === "SYSTEM_FAILURE" ||
run.status === "TIMED_OUT",
isPartial:
run.status === "DELAYED" ||
run.status === "PENDING" ||
run.status === "PAUSED" ||
run.status === "RETRYING_AFTER_FAILURE" ||
run.status === "DEQUEUED" ||
run.status === "EXECUTING" ||
run.status === "WAITING_TO_RESUME",
isCancelled: run.status === "CANCELED",
isDebug: false,
level: "TRACE",
},
};
traceSummary = {
rootSpan: spanSummary,
spans: [spanSummary],
};
}
// Control-plane read (User table) — stays on the control-plane client, NOT
// routed through the run-ops store (user resolved CP-side, run run-ops-side).
const user = await this.#prismaClient.user.findFirst({
where: {
id: userId,
},
select: {
admin: true,
},
});
// Resolve agent-kind once so the tree renderer can swap icon/colour for
// the current run's spans without doing per-row lookups.
const isAgentRun = RunAnnotations.safeParse(run.annotations).data?.taskKind === "AGENT";
//this tree starts at the passed in span (hides parent elements if there are any)
const tree = createTreeFromFlatItems(traceSummary.spans, run.spanId);
const missingAnchor = !traceSummary.spans.some((span) => span.id === run.spanId) || !tree;
if (missingAnchor) {
logger.warn("Trace view anchor span not found in trace summary", {
runId: run.friendlyId,
spanId: run.spanId,
traceId: run.traceId,
spanCount: traceSummary.spans.length,
});
isTruncated = true;
}
//we need the start offset for each item, and the total duration of the entire tree
const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0;
let totalDuration = tree?.data.duration ?? 0;
// Build the linkedRunIdBySpanId map during the same walk
const linkedRunIdBySpanId: Record<string, string> = {};
const events = tree
? flattenTree(tree).map((n) => {
const offset = millisecondsToNanoseconds(
n.data.startTime.getTime() - treeRootStartTimeMs
);
//only let non-debug events extend the total duration
if (!n.data.isDebug) {
totalDuration = Math.max(totalDuration, offset + n.data.duration);
}
// For cached spans, store the mapping from spanId to the linked run's ID
if (n.data.style?.icon === "task-cached" && n.runId) {
linkedRunIdBySpanId[n.id] = n.runId;
}
// Raw span events are only needed server-side (to derive timelineEvents);
// keep them out of the serialized loader payload.
const { events: spanEvents, ...data } = n.data;
return {
...n,
data: {
...data,
timelineEvents: createTimelineSpanEventsFromSpanEvents(
spanEvents,
user?.admin ?? false,
treeRootStartTimeMs
),
//set partial nodes to null duration
duration: n.data.isPartial ? null : n.data.duration,
offset,
isRoot: n.id === traceSummary.rootSpan.id,
isAgentRun: n.runId === run.friendlyId && isAgentRun,
},
};
})
: [];
//total duration should be a minimum of 1ms
totalDuration = Math.max(totalDuration, millisecondsToNanoseconds(1));
let rootSpanStatus: "executing" | "completed" | "failed" = "executing";
if (events[0]) {
if (events[0].data.isError) {
rootSpanStatus = "failed";
} else if (!events[0].data.isPartial) {
rootSpanStatus = "completed";
}
}
return {
run: runData,
trace: {
rootSpanStatus,
events: events,
duration: totalDuration,
rootStartedAt: tree?.data.startTime,
startedAt: run.startedAt,
queuedDuration: run.startedAt
? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime())
: undefined,
overridesBySpanId: traceSummary.overridesBySpanId,
linkedRunIdBySpanId,
isTruncated,
missingAnchor,
},
maximumLiveReloadingSetting: repository.maximumLiveReloadingSetting,
};
}
}
@@ -0,0 +1,211 @@
import { type PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { singleton } from "~/utils/singleton";
import type { SendFunction } from "~/utils/sse";
import { ABORT_REASON_SEND_ERROR, createSSELoader } from "~/utils/sse";
import { throttle } from "~/utils/throttle";
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
import { deserialiseMollifierSnapshot } from "~/v3/mollifier/mollifierSnapshot.server";
import { runStore } from "~/v3/runStore.server";
import { tracePubSub } from "~/v3/services/tracePubSub.server";
const PING_INTERVAL = 5_000;
const STREAM_TIMEOUT = 30_000;
export class RunStreamPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public createLoader() {
const prismaClient = this.#prismaClient;
return createSSELoader({
timeout: STREAM_TIMEOUT,
interval: PING_INTERVAL,
handler: async (context) => {
const runFriendlyId = context.params.runParam;
if (!runFriendlyId) {
throw new Response("Missing runParam", { status: 400 });
}
const userId = await requireUserId(context.request);
// Scope the lookup to organizations the requesting user is a member
// of, matching RunPresenter's run lookup. Unauthorized and missing
// runs are indistinguishable (both 404).
// Run-ops read by friendlyId only (routes to the owning DB); the project/org
// membership auth is a control-plane concern resolved separately below — joining
// it here is a cross-DB join that returns nothing once the run lives in run-ops.
const run = await runStore.findRun(
{
friendlyId: runFriendlyId,
},
{
select: {
traceId: true,
projectId: true,
},
}
);
// Authorize on the control-plane DB, keyed by the run's project. A non-member
// (or unresolvable project) is treated as no-access: traceId stays null.
let authorized = false;
if (run) {
const authorizedProject = await prismaClient.project.findFirst({
where: {
id: run.projectId,
organization: { members: { some: { userId } } },
},
select: { id: true },
});
authorized = authorizedProject !== null;
}
// Fall back to the mollifier buffer when the run isn't in PG yet.
// The buffered run has no execution events to stream, but we still
// attach a trace-pubsub subscription using the snapshot's traceId
// so that the moment the drainer materialises the row and execution
// begins, those events flow to this open SSE connection. Closing
// with 404 would force the dashboard to keep retrying.
let traceId: string | null = run && authorized ? run.traceId : null;
if (!traceId) {
const buffer = getMollifierBuffer();
if (buffer) {
try {
const entry = await buffer.getEntry(runFriendlyId);
// Same membership scoping as the PG lookup above — the buffer
// entry carries the owning org's id.
const isMember = entry
? (await prismaClient.orgMember.findFirst({
where: { organizationId: entry.orgId, userId },
select: { id: true },
})) !== null
: false;
if (entry && isMember) {
// Go through the webapp wrapper so this read-side module
// shares a single deserialisation path with readFallback —
// see the contract comment in syntheticRedirectInfo.server.ts.
const snapshot = deserialiseMollifierSnapshot(entry.payload);
if (typeof snapshot.traceId === "string") {
traceId = snapshot.traceId;
}
}
} catch (err) {
logger.warn("RunStreamPresenter buffer fallback failed", {
runFriendlyId,
err: err instanceof Error ? err.message : String(err),
});
}
}
}
if (!traceId) {
throw new Response("Not found", { status: 404 });
}
const resolvedRun = { traceId };
logger.info("RunStreamPresenter.start", {
runFriendlyId,
traceId: resolvedRun.traceId,
});
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(
resolvedRun.traceId
);
// Only send max every 1 second
const throttledSend = throttle(
(args: { send: SendFunction; event?: string; data: string }) => {
try {
args.send({ event: args.event, data: args.data });
} catch (error) {
if (error instanceof Error) {
if (error.name !== "TypeError") {
logger.debug("Error sending SSE in RunStreamPresenter", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
}
}
// Abort the stream on send error. Uses a stackless string sentinel
// from sse.ts — a no-arg abort() would create a DOMException with a
// stack trace, which is unnecessary retention on the signal.reason.
context.controller.abort(ABORT_REASON_SEND_ERROR);
}
},
1000
);
let messageListener: ((event: string) => void) | undefined;
return {
initStream: ({ send }) => {
throttledSend({ send, event: "message", data: new Date().toISOString() });
messageListener = (event: string) => {
throttledSend({ send, event: "message", data: event });
};
eventEmitter.addListener("message", messageListener);
context.debug("Subscribed to trace pub/sub");
},
iterator: ({ send }) => {
try {
// Send an actual message so the client refreshes
throttledSend({ send, event: "message", data: new Date().toISOString() });
} catch (_error) {
// If we can't send a ping, the connection is likely dead
return false;
}
},
cleanup: () => {
logger.info("RunStreamPresenter.cleanup", {
runFriendlyId,
traceId: resolvedRun.traceId,
});
if (messageListener) {
eventEmitter.removeListener("message", messageListener);
}
eventEmitter.removeAllListeners();
unsubscribe()
.then(() => {
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
runFriendlyId,
traceId: resolvedRun.traceId,
});
})
.catch((error) => {
logger.error("RunStreamPresenter.cleanup.unsubscribe failed", {
runFriendlyId,
traceId: resolvedRun.traceId,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
});
},
};
},
});
}
}
export const runStreamLoader = singleton("runStreamLoader", () => {
const presenter = new RunStreamPresenter();
return presenter.createLoader();
});
@@ -0,0 +1,67 @@
import { type PrismaClient } from "@trigger.dev/database";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { BasePresenter } from "./basePresenter.server";
export type TagListOptions = {
organizationId: string;
environmentId: string;
projectId: string;
period?: string;
from?: Date;
to?: Date;
//filters
name?: string;
//pagination
page?: number;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type TagList = Awaited<ReturnType<RunTagListPresenter["call"]>>;
export type TagListItem = TagList["tags"][number];
export class RunTagListPresenter extends BasePresenter {
public async call({
organizationId,
environmentId,
projectId,
name,
period,
from,
to,
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
}: TagListOptions) {
const hasFilters = Boolean(name?.trim());
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const runsRepository = new RunsRepository({
clickhouse,
prisma: this._replica as PrismaClient,
});
const tags = await runsRepository.listTags({
organizationId,
projectId,
environmentId,
query: name,
period,
from: from ? from.getTime() : undefined,
to: to ? to.getTime() : undefined,
offset: (page - 1) * pageSize,
limit: pageSize + 1,
});
return {
tags: tags.tags,
currentPage: page,
hasMore: tags.tags.length > pageSize,
hasFilters,
};
}
}
@@ -0,0 +1,350 @@
import { type RuntimeEnvironmentType, type ScheduleType } from "@trigger.dev/database";
import { type ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
import {
calculateNextScheduledTimestampFromNow,
previousScheduledTimestamp,
} from "~/v3/utils/calculateNextSchedule.server";
import { BasePresenter } from "./basePresenter.server";
type ScheduleListOptions = {
projectId: string;
environmentId: string;
userId?: string;
pageSize?: number;
} & ScheduleListFilters;
const DEFAULT_PAGE_SIZE = 20;
export type ScheduleListItem = {
id: string;
type: ScheduleType;
friendlyId: string;
taskIdentifier: string;
deduplicationKey: string | null;
userProvidedDeduplicationKey: boolean;
cron: string;
cronDescription: string;
timezone: string;
externalId: string | null;
nextRun: Date;
lastRun: Date | undefined;
active: boolean;
environments: {
id: string;
type: RuntimeEnvironmentType;
userName?: string;
branchName?: string;
}[];
};
export type ScheduleList = Awaited<ReturnType<ScheduleListPresenter["call"]>>;
export type ScheduleListAppliedFilters = ScheduleList["filters"];
export class ScheduleListPresenter extends BasePresenter {
public async call({
userId,
projectId,
environmentId,
tasks,
search,
page,
type,
pageSize = DEFAULT_PAGE_SIZE,
}: ScheduleListOptions) {
const hasFilters =
type !== undefined || tasks !== undefined || (search !== undefined && search !== "");
const filterType =
type === "declarative" ? "DECLARATIVE" : type === "imperative" ? "IMPERATIVE" : undefined;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
organizationId: true,
environments: {
select: {
id: true,
type: true,
slug: true,
branchName: true,
archivedAt: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
id: projectId,
},
});
const environment = project.environments.find((env) => env.id === environmentId);
if (!environment) {
throw new ServiceValidationError("No matching environment for project", 404);
}
const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({
prisma: this._replica,
projectId,
});
// Two platform RPCs in parallel. We derive `limit` from `currentPlan`
// rather than calling `getLimit` separately (which would re-issue
// `client.currentPlan` upstream — same data fetched twice). The
// scheduled-task route awaits this presenter synchronously, so every ms
// here is TTFB.
const [currentPlan, plans] = await Promise.all([
getCurrentPlan(project.organizationId),
getPlans(),
]);
const planLimit = currentPlan?.v3Subscription?.plan?.limits.schedules?.number;
const limit = typeof planLimit === "number" ? planLimit : 100_000_000;
const extraSchedules = currentPlan?.v3Subscription?.addOns?.schedules?.purchased ?? 0;
const canPurchaseSchedules =
currentPlan?.v3Subscription?.plan?.limits.schedules.canExceed === true;
const maxScheduleQuota = currentPlan?.v3Subscription?.addOns?.schedules?.quota ?? 0;
const planScheduleLimit = limit - extraSchedules;
const schedulePricing = plans?.addOnPricing.schedules ?? null;
const purchaseInfo = {
canPurchaseSchedules,
extraSchedules,
maxScheduleQuota,
planScheduleLimit,
schedulePricing,
};
//get the latest BackgroundWorker
const latestWorker = await findCurrentWorkerFromEnvironment(environment, this._replica);
if (!latestWorker) {
return {
currentPage: 1,
totalPages: 1,
totalCount: 0,
schedules: [],
possibleTasks: [],
hasFilters,
limits: {
used: schedulesCount,
limit,
},
...purchaseInfo,
filters: {
tasks,
search,
},
};
}
//get all possible scheduled tasks
const allIdentifiers = await getTaskIdentifiers(environmentId);
const possibleTasks = allIdentifiers
.filter((t) => t.triggerSource === "SCHEDULED" && t.isInLatestDeployment)
.map((t) => ({ slug: t.slug }));
//do this here to protect against SQL injection
search = search && search !== "" ? `%${search}%` : undefined;
const totalCount = await this._replica.taskSchedule.count({
where: {
projectId: project.id,
taskIdentifier: tasks ? { in: tasks } : undefined,
instances: {
some: {
environmentId,
},
},
type: filterType,
AND: search
? {
OR: [
{
externalId: {
contains: search,
mode: "insensitive",
},
},
{
friendlyId: {
contains: search,
mode: "insensitive",
},
},
{
deduplicationKey: {
contains: search,
mode: "insensitive",
},
},
{
generatorExpression: {
contains: search,
mode: "insensitive",
},
},
],
}
: undefined,
},
});
const rawSchedules = await this._replica.taskSchedule.findMany({
select: {
id: true,
type: true,
friendlyId: true,
taskIdentifier: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
generatorExpression: true,
generatorDescription: true,
timezone: true,
externalId: true,
instances: {
select: {
environmentId: true,
},
},
active: true,
createdAt: true,
updatedAt: true,
},
where: {
projectId: project.id,
taskIdentifier: tasks ? { in: tasks } : undefined,
instances: {
some: {
environmentId,
},
},
type: filterType,
AND: search
? {
OR: [
{
externalId: {
contains: search,
mode: "insensitive",
},
},
{
friendlyId: {
contains: search,
mode: "insensitive",
},
},
{
deduplicationKey: {
contains: search,
mode: "insensitive",
},
},
{
generatorExpression: {
contains: search,
mode: "insensitive",
},
},
],
}
: undefined,
},
orderBy: {
createdAt: "desc",
},
take: pageSize,
skip: (page - 1) * pageSize,
});
const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
// Approximate "last run" from the cron's previous slot. Skip inactive
// schedules — the cron's previous slot reflects what *would* have
// fired, but a deactivated schedule didn't actually fire there. Skip
// when the cron's previous slot predates `updatedAt`: any config
// change (cron edited, timezone changed, deactivate/reactivate)
// bumps updatedAt, and a slot from before the most recent change
// didn't fire under the current configuration. cron-parser throws
// on malformed expressions, so degrade to undefined per-row rather
// than failing the whole list. UI is best-effort; the runs page is
// the source of truth.
let lastRun: Date | undefined;
if (schedule.active) {
try {
const cronPrev = previousScheduledTimestamp(
schedule.generatorExpression,
schedule.timezone
);
lastRun = cronPrev.getTime() > schedule.updatedAt.getTime() ? cronPrev : undefined;
} catch {
lastRun = undefined;
}
}
return {
id: schedule.id,
type: schedule.type,
friendlyId: schedule.friendlyId,
taskIdentifier: schedule.taskIdentifier,
deduplicationKey: schedule.deduplicationKey,
userProvidedDeduplicationKey: schedule.userProvidedDeduplicationKey,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
timezone: schedule.timezone,
active: schedule.active,
externalId: schedule.externalId,
lastRun,
nextRun: calculateNextScheduledTimestampFromNow(
schedule.generatorExpression,
schedule.timezone
),
environments: schedule.instances.map((instance) => {
const environment = project.environments.find((env) => env.id === instance.environmentId);
if (!environment) {
throw new Error(
`Environment not found for TaskScheduleInstance env: ${instance.environmentId}`
);
}
return {
...displayableEnvironment(environment, userId),
branchName: environment.branchName ?? undefined,
};
}),
};
});
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
totalCount: totalCount,
schedules,
possibleTasks: possibleTasks.map((task) => task.slug),
hasFilters,
limits: {
used: schedulesCount,
limit,
},
...purchaseInfo,
filters: {
tasks,
search,
},
};
}
}
@@ -0,0 +1,258 @@
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,
};
}
}
@@ -0,0 +1,203 @@
import { type Span } from "@opentelemetry/api";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { env } from "~/env.server";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server";
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
import { LEGACY_PLAYGROUND_TAG } from "~/services/sessionsRepository/sessionsRepository.server";
import { logger } from "~/services/logger.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
import { runStore } from "~/v3/runStore.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { startActiveSpan } from "~/v3/tracer.server";
export type SessionDetail = NonNullable<Awaited<ReturnType<SessionPresenter["call"]>>>;
export class SessionPresenter {
constructor(private readonly replica: PrismaClientOrTransaction) {}
public async call(args: {
userId: string;
environmentId: string;
sessionParam: string;
projectExternalRef: string;
environmentSlug: string;
}) {
return startActiveSpan("SessionPresenter.call", (span) => this.#call(args, span), {
attributes: {
environmentId: args.environmentId,
sessionParam: args.sessionParam,
},
});
}
async #call(
{
userId,
environmentId,
sessionParam,
projectExternalRef,
environmentSlug,
}: {
userId: string;
environmentId: string;
sessionParam: string;
projectExternalRef: string;
environmentSlug: string;
},
rootSpan: Span
) {
const session = await startActiveSpan("SessionPresenter.resolveSession", () =>
resolveSessionByIdOrExternalId(this.replica, environmentId, sessionParam)
);
if (!session) {
rootSpan.setAttribute("session.found", false);
return null;
}
rootSpan.setAttribute("session.found", true);
rootSpan.setAttribute("session.id", session.id);
const displayableEnvironment = await startActiveSpan(
"SessionPresenter.findDisplayableEnvironment",
() => findDisplayableEnvironment(environmentId, userId)
);
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
// Run history is append-only; latest first matches the runs list.
// 50 covers the vast majority of sessions; longer histories link out
// to the runs page via tag filter.
const sessionRuns = await startActiveSpan("SessionPresenter.findSessionRuns", async (span) => {
const rows = await this.replica.sessionRun.findMany({
where: { sessionId: session.id },
orderBy: { triggeredAt: "desc" },
take: 50,
select: {
id: true,
runId: true,
reason: true,
triggeredAt: true,
},
});
span.setAttribute("sessionRuns.count", rows.length);
return rows;
});
const runIds = sessionRuns.map((r) => r.runId);
const runs = await startActiveSpan("SessionPresenter.findRuns", async (span) => {
span.setAttribute("runIds.count", runIds.length);
return runIds.length > 0
? runStore.findRuns(
{
where: { id: { in: runIds } },
select: { id: true, friendlyId: true, status: true },
},
this.replica
)
: [];
});
const runsById = new Map(runs.map((r) => [r.id, r] as const));
const currentRun = session.currentRunId
? (runsById.get(session.currentRunId) ??
(await startActiveSpan("SessionPresenter.findCurrentRunFallback", () =>
runStore.findRun(
{ id: session.currentRunId! },
{
select: { id: true, friendlyId: true, status: true },
},
this.replica
)
)))
: null;
// The dashboard SSE route is cookie-authed, so `publicAccessToken` is
// unused — kept here to match the existing `AgentViewAuth` shape.
const addressingKey = session.externalId ?? session.friendlyId;
// Presign a GET URL for the agent's S3 snapshot blob. The browser
// fetches it directly, parses + validates, and seeds the
// TriggerChatTransport with the full history + lastEventId before
// opening the SSE. Presign succeeds regardless of whether the blob
// exists; the frontend handles 404 gracefully.
//
// Snapshots are only written when no `hydrateMessages` hook is
// registered — sessions that use `hydrateMessages` will 404 here
// and the dashboard falls back to seq=0 SSE (which, post-trim,
// shows only the most recent turn — accepted, those customers
// have their own DB-backed dashboards).
// Resolve the snapshot key via the SAME helper the SDK write + boot read
// use (`chatSnapshotStorageKey`), so the dashboard GET hits the exact
// object (and object store) the snapshot was written to. Recomputing a
// bare key here was the bug: an unqualified key reads the base store while
// the write applied OBJECT_STORE_DEFAULT_PROTOCOL, so they could diverge.
let snapshotPresignedUrl: string | undefined;
try {
const signed = await startActiveSpan("SessionPresenter.presignSnapshot", async () =>
generatePresignedUrl(
projectExternalRef,
environmentSlug,
chatSnapshotStorageKey(session),
"GET"
)
);
if (signed.success) {
snapshotPresignedUrl = signed.url;
} else {
logger.warn("SessionPresenter: snapshot presign failed", {
sessionId: session.id,
error: signed.error,
});
}
} catch (error) {
logger.warn("SessionPresenter: snapshot presign threw", {
sessionId: session.id,
error: error instanceof Error ? error.message : String(error),
});
}
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))
: [],
metadata: session.metadata,
triggerConfig: session.triggerConfig,
streamBasinName: session.streamBasinName,
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,
currentRun: currentRun
? { friendlyId: currentRun.friendlyId, status: currentRun.status }
: null,
runs: sessionRuns.map((r) => {
const run = runsById.get(r.runId);
return {
id: r.id,
reason: r.reason,
triggeredAt: r.triggeredAt.toISOString(),
run: run ? { friendlyId: run.friendlyId, status: run.status } : null,
};
}),
agentView: {
publicAccessToken: "",
apiOrigin: env.API_ORIGIN || env.LOGIN_ORIGIN,
sessionId: addressingKey,
initialMessages: [],
snapshotPresignedUrl,
},
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
import { type ClickHouse } from "@internal/clickhouse";
import { type MachinePresetName, RetryOptions } from "@trigger.dev/core/v3";
import {
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskTriggerSource,
} from "@trigger.dev/database";
import { z } from "zod";
import { machinePresetFromConfig } from "~/v3/machinePresets.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import {
chooseBucketSeconds,
groupRunStatus,
RUN_STATUS_GROUPS,
zeroFillGroupedSeries,
} from "./activitySeries.server";
export type TaskDetailQueue = {
friendlyId: string;
name: string;
concurrencyLimit: number | null;
paused: boolean;
};
export type TaskDetailRetry = {
maxAttempts?: number;
factor?: number;
minTimeoutInMs?: number;
maxTimeoutInMs?: number;
randomize?: boolean;
};
export type TaskDetail = {
slug: string;
filePath: string;
exportName: string | null;
description: string | null;
triggerSource: TaskTriggerSource;
createdAt: Date;
config: unknown;
workerVersion: string | null;
queue: TaskDetailQueue | null;
machinePreset: MachinePresetName;
maxDurationInSeconds: number | null;
ttl: string | null;
retry: TaskDetailRetry | null;
hasPayloadSchema: boolean;
};
export type TaskActivityPoint = {
bucket: number;
} & Record<string, number>;
export type TaskActivity = {
data: TaskActivityPoint[];
statuses: string[];
};
export class TaskDetailPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {}
async findTask({
environmentId,
environmentType,
taskSlug,
expectedTriggerSource,
}: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
taskSlug: string;
expectedTriggerSource?: TaskTriggerSource;
}): Promise<TaskDetail | null> {
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: environmentId, type: environmentType },
this.replica
);
if (!currentWorker) return null;
const task = await this.replica.backgroundWorkerTask.findFirst({
where: {
workerId: currentWorker.id,
slug: taskSlug,
...(expectedTriggerSource ? { triggerSource: expectedTriggerSource } : {}),
},
select: {
slug: true,
filePath: true,
exportName: true,
description: true,
triggerSource: true,
config: true,
createdAt: true,
machineConfig: true,
retryConfig: true,
maxDurationInSeconds: true,
ttl: true,
payloadSchema: true,
queue: {
select: {
friendlyId: true,
name: true,
concurrencyLimit: true,
paused: true,
},
},
},
});
if (!task) return null;
const retryParsed = RetryOptions.safeParse(task.retryConfig ?? undefined);
const retry: TaskDetailRetry | null = retryParsed.success
? {
maxAttempts: retryParsed.data.maxAttempts,
factor: retryParsed.data.factor,
minTimeoutInMs: retryParsed.data.minTimeoutInMs,
maxTimeoutInMs: retryParsed.data.maxTimeoutInMs,
randomize: retryParsed.data.randomize,
}
: null;
return {
slug: task.slug,
filePath: task.filePath,
exportName: task.exportName,
description: task.description,
triggerSource: task.triggerSource,
createdAt: task.createdAt,
config: task.config,
workerVersion: currentWorker.version,
queue: task.queue,
machinePreset: machinePresetFromConfig(task.machineConfig ?? {}).name,
maxDurationInSeconds: task.maxDurationInSeconds,
ttl: task.ttl,
retry,
hasPayloadSchema: task.payloadSchema !== null && task.payloadSchema !== undefined,
};
}
async getActivity({
organizationId,
projectId,
environmentId,
taskSlug,
from,
to,
}: {
organizationId: string;
projectId: string;
environmentId: string;
taskSlug: string;
from: Date;
to: Date;
}): Promise<TaskActivity> {
const rangeMs = Math.max(1, to.getTime() - from.getTime());
const bucketSeconds = chooseBucketSeconds(rangeMs);
// FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree;
// org/project filters engage the sort-key prefix for partition pruning.
const queryFn = this.clickhouse.reader.query({
name: "taskRunStatusActivity",
query: `SELECT
toUnixTimestamp(toStartOfInterval(created_at, INTERVAL {bucketSeconds: UInt32} SECOND)) AS bucket,
status,
count() AS val
FROM trigger_dev.task_runs_v2 FINAL
WHERE organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND task_identifier = {taskSlug: String}
AND created_at >= {fromTime: DateTime64(3, 'UTC')}
AND created_at < {toTime: DateTime64(3, 'UTC')}
AND _is_deleted = 0
GROUP BY bucket, status
ORDER BY bucket`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
taskSlug: z.string(),
bucketSeconds: z.number(),
fromTime: z.string(),
toTime: z.string(),
}),
schema: z.object({
bucket: z.coerce.number(),
status: z.string(),
val: z.coerce.number(),
}),
});
const [error, rows] = await queryFn({
organizationId,
projectId,
environmentId,
taskSlug,
bucketSeconds,
// ClickHouse's DateTime64(3, 'UTC') parser rejects the trailing `Z` from
// JS toISOString() ("only 23 of 24 bytes was parsed"). Strip it.
fromTime: from.toISOString().slice(0, -1),
toTime: to.toISOString().slice(0, -1),
});
if (error) {
console.error("Task activity query failed:", error);
return { data: [], statuses: [] };
}
// Always emit every status group so the chart legend is stable across time
// ranges (even when a group has no runs in the current window).
const points = zeroFillGroupedSeries({
rows,
from,
to,
bucketSeconds,
orderedKeys: RUN_STATUS_GROUPS,
groupFn: groupRunStatus,
fallbackKey: "RUNNING",
});
return { data: points, statuses: [...RUN_STATUS_GROUPS] };
}
}
@@ -0,0 +1,126 @@
import {
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskTriggerSource,
} from "@trigger.dev/database";
import { $replica } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import {
type AverageDurations,
ClickHouseEnvironmentMetricsRepository,
type CurrentRunningStats,
type DailyTaskActivity,
} from "~/services/environmentMetricsRepository.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
export type TaskListItem = {
slug: string;
filePath: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
};
export type TaskActivity = DailyTaskActivity[string];
export class TaskListPresenter {
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call({
organizationId,
projectId,
environmentId,
environmentType,
currentWorker: preloadedCurrentWorker,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
/** Optional: pass the pre-resolved current worker to skip the lookup. Used
* by `UnifiedTaskListPresenter` to share one lookup across both presenters. */
currentWorker?: Awaited<ReturnType<typeof findCurrentWorkerFromEnvironment>>;
}) {
const currentWorker =
preloadedCurrentWorker !== undefined
? preloadedCurrentWorker
: await findCurrentWorkerFromEnvironment(
{
id: environmentId,
type: environmentType,
},
this._replica
);
if (!currentWorker) {
return {
tasks: [],
activity: Promise.resolve({} as DailyTaskActivity),
runningStats: Promise.resolve({} as CurrentRunningStats),
durations: Promise.resolve({} as AverageDurations),
};
}
const tasks = await this._replica.backgroundWorkerTask.findMany({
where: {
workerId: currentWorker.id,
triggerSource: { not: "AGENT" },
},
select: {
id: true,
slug: true,
filePath: true,
triggerSource: true,
createdAt: true,
},
orderBy: {
slug: "asc",
},
});
const slugs = tasks.map((t) => t.slug);
// Create org-specific environment metrics repository
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const environmentMetricsRepository = new ClickHouseEnvironmentMetricsRepository({
clickhouse,
});
// IMPORTANT: Don't await these, we want to return the promises
// so we can defer the loading of the data
const activity = environmentMetricsRepository.getDailyTaskActivity({
organizationId,
projectId,
environmentId,
days: 6, // This actually means 7 days, because we want to show the current day too
tasks: slugs,
});
const runningStats = environmentMetricsRepository.getCurrentRunningStats({
organizationId,
projectId,
environmentId,
days: 6,
tasks: slugs,
});
const durations = environmentMetricsRepository.getAverageDurations({
organizationId,
projectId,
environmentId,
days: 6,
tasks: slugs,
});
return { tasks, activity, runningStats, durations };
}
}
export const taskListPresenter = singleton("taskListPresenter", setupTaskListPresenter);
function setupTaskListPresenter() {
return new TaskListPresenter($replica);
}
@@ -0,0 +1,83 @@
import type { BackgroundWorkerTask } from "@trigger.dev/database";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import type { Project } from "~/models/project.server";
import type { User } from "~/models/user.server";
export class TaskPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
taskFriendlyId,
projectSlug,
}: {
userId: User["id"];
taskFriendlyId: BackgroundWorkerTask["friendlyId"];
projectSlug: Project["slug"];
}) {
const task = await this.#prismaClient.backgroundWorkerTask.findFirst({
select: {
id: true,
slug: true,
filePath: true,
friendlyId: true,
createdAt: true,
worker: {
select: {
id: true,
version: true,
sdkVersion: true,
cliVersion: true,
createdAt: true,
updatedAt: true,
friendlyId: true,
},
},
runtimeEnvironment: {
select: {
id: true,
slug: true,
type: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
friendlyId: taskFriendlyId,
runtimeEnvironment: {
organization: {
members: {
some: {
userId,
},
},
},
},
project: {
slug: projectSlug,
},
},
});
if (!task) {
return undefined;
}
return task;
}
}
@@ -0,0 +1,151 @@
import { type PrismaClientOrTransaction, type RuntimeEnvironmentType } from "@trigger.dev/database";
import { type ClickHouse } from "@internal/clickhouse";
import { z } from "zod";
import { $replica } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
const DAYS = 7;
export type TaskKind = "AGENT" | "STANDARD" | "SCHEDULED";
export type DailyRunPoint = {
/** ISO date (YYYY-MM-DD, UTC) */
day: string;
count: number;
};
export type TasksDashboardResult = {
counts: {
agents: number;
standard: number;
scheduled: number;
};
series: Promise<{
agents: DailyRunPoint[];
standard: DailyRunPoint[];
scheduled: DailyRunPoint[];
}>;
};
export class TasksDashboardPresenter {
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call({
organizationId,
projectId,
environmentId,
environmentType,
}: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
}): Promise<TasksDashboardResult> {
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: environmentId, type: environmentType },
this._replica
);
if (!currentWorker) {
return {
counts: { agents: 0, standard: 0, scheduled: 0 },
series: Promise.resolve({ agents: [], standard: [], scheduled: [] }),
};
}
const tasks = await this._replica.backgroundWorkerTask.findMany({
where: { workerId: currentWorker.id },
select: { triggerSource: true },
});
const counts = { agents: 0, standard: 0, scheduled: 0 };
for (const t of tasks) {
if (t.triggerSource === "AGENT") counts.agents++;
else if (t.triggerSource === "SCHEDULED") counts.scheduled++;
else counts.standard++;
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
return {
counts,
series: this.#getDailySeries(clickhouse, { organizationId, projectId, environmentId }),
};
}
async #getDailySeries(
clickhouse: ClickHouse,
args: { organizationId: string; projectId: string; environmentId: string }
) {
// FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree;
// org/project filters engage the sort-key prefix for partition pruning.
const queryFn = clickhouse.reader.query({
name: "tasksDashboardDailySeries",
query: `SELECT
task_kind,
toDate(created_at) AS day,
count() AS val
FROM trigger_dev.task_runs_v2 FINAL
WHERE organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND created_at >= now() - INTERVAL ${DAYS} DAY
AND _is_deleted = 0
GROUP BY task_kind, day
ORDER BY day`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
}),
schema: z.object({
task_kind: z.string(),
day: z.string(),
val: z.coerce.number(),
}),
});
const [error, rows] = await queryFn(args);
if (error) {
console.error("Tasks dashboard daily series query failed:", error);
return { agents: [], standard: [], scheduled: [] };
}
const dayKeys: string[] = [];
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
for (let i = DAYS - 1; i >= 0; i--) {
const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
dayKeys.push(d.toISOString().slice(0, 10));
}
const lookup: Record<string, Map<string, number>> = {
AGENT: new Map(),
SCHEDULED: new Map(),
STANDARD: new Map(),
};
for (const row of rows) {
const bucket = lookup[row.task_kind];
if (bucket) bucket.set(row.day, row.val);
}
const buildSeries = (kind: "AGENT" | "SCHEDULED" | "STANDARD"): DailyRunPoint[] =>
dayKeys.map((day) => ({ day, count: lookup[kind].get(day) ?? 0 }));
return {
agents: buildSeries("AGENT"),
scheduled: buildSeries("SCHEDULED"),
standard: buildSeries("STANDARD"),
};
}
}
export const tasksDashboardPresenter = singleton(
"tasksDashboardPresenter",
() => new TasksDashboardPresenter($replica)
);
@@ -0,0 +1,110 @@
import { eventStream } from "remix-utils/sse/server";
import { type PrismaClient, prisma } from "~/db.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { logger } from "~/services/logger.server";
import { projectPubSub } from "~/v3/services/projectPubSub.server";
const pingInterval = 1000;
export class TasksStreamPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
request,
organizationSlug,
projectSlug,
environmentSlug,
userId,
}: {
request: Request;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
userId: string;
}) {
const project = await this.#prismaClient.project.findFirst({
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
},
});
if (!project) {
return new Response("Not found", { status: 404 });
}
logger.info("TasksStreamPresenter.call", {
projectSlug,
});
let pinger: NodeJS.Timeout | undefined = undefined;
const subscriber = await projectPubSub.subscribe(`project:${project.id}:*`);
const signal = getRequestAbortSignal();
return eventStream(signal, (send, close) => {
const safeSend = (args: { event?: string; data: string }) => {
try {
send(args);
} catch (error) {
if (error instanceof Error) {
if (error.name !== "TypeError") {
logger.debug("Error sending SSE, aborting", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
args,
});
}
} else {
logger.debug("Unknown error sending SSE, aborting", {
error,
args,
});
}
close();
}
};
subscriber.on("WORKER_CREATED", async (message) => {
safeSend({ data: message.createdAt.toISOString() });
});
pinger = setInterval(() => {
if (signal.aborted) {
return close();
}
safeSend({ event: "ping", data: new Date().toISOString() });
}, pingInterval);
return async function clear() {
logger.info("TasksStreamPresenter.abort", {
projectSlug,
});
clearInterval(pinger);
await subscriber.stopListening();
};
});
}
}
@@ -0,0 +1,62 @@
import { type RuntimeEnvironmentType, type TaskTriggerSource } from "@trigger.dev/database";
import { sqlDatabaseSchema } from "~/db.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { BasePresenter } from "./basePresenter.server";
type TaskListOptions = {
userId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
};
export type TaskList = Awaited<ReturnType<TestPresenter["call"]>>;
export type TaskListItem = NonNullable<TaskList["tasks"]>[0];
export class TestPresenter extends BasePresenter {
public async call({ userId, projectId, environmentId, environmentType }: TaskListOptions) {
const isDev = environmentType === "DEVELOPMENT";
const tasks = await this.#getTasks(environmentId, isDev);
return {
tasks: tasks.map((task) => ({
id: task.id,
taskIdentifier: task.slug,
filePath: task.filePath,
friendlyId: task.friendlyId,
triggerSource: task.triggerSource,
})),
};
}
async #getTasks(envId: string, isDev: boolean) {
if (isDev) {
return await this._replica.$queryRaw<
{
id: string;
version: string;
slug: string;
filePath: string;
friendlyId: string;
triggerSource: TaskTriggerSource;
}[]
>`WITH workers AS (
SELECT
bw.*,
ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn
FROM
${sqlDatabaseSchema}."BackgroundWorker" bw
WHERE "runtimeEnvironmentId" = ${envId}
),
latest_workers AS (SELECT * FROM workers WHERE rn = 1)
SELECT bwt.id, version, slug, "filePath", bwt."friendlyId", bwt."triggerSource"
FROM latest_workers
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
WHERE bwt."triggerSource" != 'AGENT'
ORDER BY slug ASC;`;
} else {
const currentDeployment = await findCurrentWorkerDeployment({ environmentId: envId });
return (currentDeployment?.worker?.tasks ?? []).filter((t) => t.triggerSource !== "AGENT");
}
}
}
@@ -0,0 +1,454 @@
import type { ClickHouse } from "@internal/clickhouse";
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
import {
type Prisma,
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskRunStatus,
type TaskRunTemplate,
} from "@trigger.dev/database";
import { inferSchema } from "@jsonhero/schema-infer";
import parse from "parse-duration";
import { type RunStore } from "@internal/run-store";
import { type PrismaClient } from "~/db.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { getTimezones } from "~/utils/timezones.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
// Optional run-ops read-through wiring for the recent-payloads hydrate. Omitted
// => passthrough on `this.replica` (single-DB / self-host). `legacyReplica` is a
// READ REPLICA handle only — there is no legacy-primary field.
type TestTaskReadThroughDeps = {
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
// Resolved boot constant; when false the split branch is never entered.
splitEnabled?: boolean;
};
// The byte-identical select the recent-payloads hydrate has always used; `id` is
// included so the split merge can key set-membership.
const RECENT_RUNS_SELECT = {
id: true,
queue: true,
friendlyId: true,
taskIdentifier: true,
createdAt: true,
status: true,
payload: true,
payloadType: true,
seedMetadata: true,
seedMetadataType: true,
runtimeEnvironmentId: true,
concurrencyKey: true,
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
ttl: true,
runTags: true,
} as const;
type RecentRunRow = Prisma.TaskRunGetPayload<{ select: typeof RECENT_RUNS_SELECT }>;
export type RunTemplate = TaskRunTemplate & {
scheduledTaskPayload?: ScheduledRun["payload"];
};
type TestTaskOptions = {
userId: string;
projectId: string;
environment: {
id: string;
type: RuntimeEnvironmentType;
projectId: string;
organizationId: string;
};
taskIdentifier: string;
};
type Task = {
id: string;
taskIdentifier: string;
filePath: string;
friendlyId: string;
payloadSchema?: unknown;
inferredPayloadSchema?: unknown;
};
type Queue = {
id: string;
name: string;
type: "custom" | "task";
paused: boolean;
};
export type TestTaskResult =
| {
foundTask: true;
triggerSource: "STANDARD";
queue?: Queue;
task: Task;
runs: StandardRun[];
latestVersions: string[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
taskRunTemplates: TaskRunTemplate[];
}
| {
foundTask: true;
triggerSource: "SCHEDULED";
queue?: Queue;
task: Task;
possibleTimezones: string[];
runs: ScheduledRun[];
latestVersions: string[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
taskRunTemplates: TaskRunTemplate[];
}
| {
foundTask: false;
};
export type StandardTaskResult = Extract<
TestTaskResult,
{ foundTask: true; triggerSource: "STANDARD" }
>;
export type ScheduledTaskResult = Extract<
TestTaskResult,
{ foundTask: true; triggerSource: "SCHEDULED" }
>;
type RawRun = {
id: string;
queue: string;
friendlyId: string;
createdAt: Date;
status: TaskRunStatus;
payload: string;
payloadType: string;
runtimeEnvironmentId: string;
seedMetadata?: string;
seedMetadataType?: string;
concurrencyKey?: string;
maxAttempts?: number;
maxDurationInSeconds?: number;
machinePreset?: string;
ttl?: string;
idempotencyKey?: string;
runTags: string[];
};
export type StandardRun = Omit<RawRun, "ttl"> & {
metadata?: string;
ttlSeconds?: number;
};
export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
payload: {
timestamp: Date;
lastTimestamp?: Date;
externalId?: string;
timezone: string;
};
ttlSeconds?: number;
};
export class TestTaskPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse,
private readonly readThrough?: TestTaskReadThroughDeps,
private readonly runStore: RunStore = defaultRunStore
) {}
public async call({
userId,
projectId,
environment,
taskIdentifier,
}: TestTaskOptions): Promise<TestTaskResult> {
const task =
environment.type !== "DEVELOPMENT"
? (
await findCurrentWorkerDeployment({ environmentId: environment.id })
)?.worker?.tasks.find((t) => t.slug === taskIdentifier)
: await this.replica.backgroundWorkerTask.findFirst({
where: {
slug: taskIdentifier,
runtimeEnvironmentId: environment.id,
},
orderBy: {
createdAt: "desc",
},
});
if (!task) {
return {
foundTask: false,
};
}
const taskQueue = task.queueId
? await this.replica.taskQueue.findFirst({
where: {
runtimeEnvironmentId: environment.id,
id: task.queueId,
},
select: {
friendlyId: true,
name: true,
type: true,
paused: true,
},
})
: undefined;
const backgroundWorkers = await this.replica.backgroundWorker.findMany({
where: {
runtimeEnvironmentId: environment.id,
},
select: {
version: true,
engine: true,
},
orderBy: {
createdAt: "desc",
},
take: 20, // last 20 versions should suffice
});
const taskRunTemplates = await this.replica.taskRunTemplate.findMany({
where: {
projectId,
taskSlug: task.slug,
triggerSource: task.triggerSource,
},
orderBy: {
createdAt: "desc",
},
take: 50,
});
const latestVersions = backgroundWorkers.map((v) => v.version);
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1";
// Get the latest runs, for the payloads
const runsRepository = new RunsRepository({
clickhouse: this.clickhouse,
prisma: this.replica as PrismaClient,
});
const { runIds } = await runsRepository.listRunIds({
organizationId: environment.organizationId,
environmentId: environment.id,
projectId: environment.projectId,
tasks: [task.slug],
period: "30d",
page: {
size: 10,
},
});
const latestRuns = await this.hydrateRecentRuns(runIds);
// Infer schema from existing run payloads when no explicit schema is defined
let inferredPayloadSchema: unknown | undefined;
if (!task.payloadSchema && latestRuns.length > 0 && task.triggerSource === "STANDARD") {
let inference: ReturnType<typeof inferSchema> | undefined;
for (const run of latestRuns) {
try {
const parsed = await parsePacket({ data: run.payload, dataType: run.payloadType });
inference = inferSchema(parsed, inference);
} catch {
// Skip malformed runs — inference is best-effort
}
}
if (inference) {
inferredPayloadSchema = inference.toJSONSchema();
}
}
const taskWithEnvironment = {
id: task.id,
taskIdentifier: task.slug,
filePath: task.filePath,
friendlyId: task.friendlyId,
payloadSchema: task.payloadSchema ?? undefined,
inferredPayloadSchema,
};
switch (task.triggerSource) {
case "STANDARD":
return {
foundTask: true,
triggerSource: "STANDARD",
queue: taskQueue
? {
id: taskQueue.friendlyId,
name: taskQueue.name.replace(/^task\//, ""),
type: queueTypeFromType(taskQueue.type),
paused: taskQueue.paused,
}
: undefined,
task: taskWithEnvironment,
runs: await Promise.all(
latestRuns.map(
async (r) =>
({
...r,
seedMetadata: r.seedMetadata ?? undefined,
seedMetadataType: r.seedMetadataType ?? undefined,
concurrencyKey: r.concurrencyKey ?? undefined,
maxAttempts: r.maxAttempts ?? undefined,
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
machinePreset: r.machinePreset ?? undefined,
payload: await prettyPrintPacket(r.payload, r.payloadType),
metadata: r.seedMetadata
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
: undefined,
ttlSeconds: r.ttl ? (parse(r.ttl, "s") ?? undefined) : undefined,
}) satisfies StandardRun
)
),
latestVersions,
disableVersionSelection,
allowArbitraryQueues,
taskRunTemplates: await Promise.all(
taskRunTemplates.map(async (t) => ({
...t,
payload: await prettyPrintPacket(t.payload, t.payloadType),
metadata: t.metadata ? await prettyPrintPacket(t.metadata, t.metadataType) : null,
}))
),
};
case "SCHEDULED": {
const possibleTimezones = getTimezones();
return {
foundTask: true,
triggerSource: "SCHEDULED",
queue: taskQueue
? {
id: taskQueue.friendlyId,
name: taskQueue.name.replace(/^task\//, ""),
type: queueTypeFromType(taskQueue.type),
paused: taskQueue.paused,
}
: undefined,
task: taskWithEnvironment,
possibleTimezones,
runs: (
await Promise.all(
latestRuns.map(async (r) => {
const payload = await getScheduleTaskRunPayload(r.payload, r.payloadType);
if (payload.success) {
return {
...r,
seedMetadata: r.seedMetadata ?? undefined,
seedMetadataType: r.seedMetadataType ?? undefined,
concurrencyKey: r.concurrencyKey ?? undefined,
maxAttempts: r.maxAttempts ?? undefined,
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
machinePreset: r.machinePreset ?? undefined,
payload: payload.data,
ttlSeconds: r.ttl ? (parse(r.ttl, "s") ?? undefined) : undefined,
} satisfies ScheduledRun;
}
})
)
).filter(Boolean),
latestVersions,
disableVersionSelection,
allowArbitraryQueues,
taskRunTemplates: await Promise.all(
taskRunTemplates.map(async (t) => {
const scheduledTaskPayload = t.payload
? await getScheduleTaskRunPayload(t.payload, t.payloadType)
: undefined;
return {
...t,
scheduledTaskPayload:
scheduledTaskPayload && scheduledTaskPayload.success
? scheduledTaskPayload.data
: undefined,
};
})
),
};
}
case "AGENT": {
// AGENT tasks are filtered out by TestPresenter and shouldn't reach here
return { foundTask: false };
}
default: {
return task.triggerSource satisfies never;
}
}
}
// Runs the recent-payloads find on one client, preserving the byte-identical
// select, the payloadType IN filter, and the createdAt-desc order on every
// store this hydrate touches.
private hydrateOnClient(
client: PrismaClientOrTransaction,
ids: string[]
): Promise<RecentRunRow[]> {
return this.runStore.findRuns(
{
where: {
id: { in: ids },
payloadType: { in: ["application/json", "application/super+json"] },
},
select: RECENT_RUNS_SELECT,
orderBy: { createdAt: "desc" },
},
client
);
}
// Hydrates the recent-payloads run-id set from the run-ops store. Split on: new
// client first, then the LEGACY READ REPLICA ONLY for ids that miss on new —
// never the legacy primary. Split off: one plain findRuns on `this.replica`.
private async hydrateRecentRuns(runIds: string[]): Promise<RecentRunRow[]> {
if (runIds.length === 0) {
return [];
}
if (!this.readThrough?.splitEnabled) {
return this.hydrateOnClient(this.readThrough?.newClient ?? this.replica, runIds);
}
const newClient = this.readThrough.newClient ?? this.replica;
const legacyReplica = this.readThrough.legacyReplica ?? this.replica;
const newRows = await this.hydrateOnClient(newClient, runIds);
const foundIds = new Set(newRows.map((r) => r.id));
// Probe every id that missed on new against the legacy read replica.
const toProbeLegacy = runIds.filter((id) => !foundIds.has(id));
const legacyRows = toProbeLegacy.length
? await this.hydrateOnClient(legacyReplica, toProbeLegacy)
: [];
// Re-impose createdAt-desc across the two finds to match single-DB ordering,
// with an id-desc tie-break so identical timestamps stay deterministic.
return [...newRows, ...legacyRows].sort((a, b) => {
const byCreatedAt = b.createdAt.getTime() - a.createdAt.getTime();
return byCreatedAt !== 0 ? byCreatedAt : a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
});
}
}
async function getScheduleTaskRunPayload(payload: string, payloadType: string) {
const packet = await parsePacket({ data: payload, dataType: payloadType });
if (!packet.timezone) {
packet.timezone = "UTC";
}
const parsed = ScheduledTaskPayload.safeParse(packet);
return parsed;
}
@@ -0,0 +1,275 @@
import { type ClickHouse } from "@internal/clickhouse";
import {
type PrismaClientOrTransaction,
type RuntimeEnvironmentType,
type TaskRunStatus,
type TaskTriggerSource,
} from "@trigger.dev/database";
import { z } from "zod";
import { $replica } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server";
import { taskListPresenter, type TaskListItem } from "./TaskListPresenter.server";
export type UnifiedTaskKind = "STANDARD" | "SCHEDULED" | "AGENT";
export type UnifiedTaskListItem = {
kind: UnifiedTaskKind;
slug: string;
filePath: string;
triggerSource: TaskTriggerSource;
createdAt: Date;
/** Agent-only: parsed `config.type` used for the Type badge. */
agentType?: string;
};
export type UnifiedRunningState =
| { kind: "task"; running: number }
| { kind: "agent"; running: number; suspended: number };
export type UnifiedRunningStates = Record<string, UnifiedRunningState>;
/** One hour bucket: the bucket start date, a total count for axis scaling,
* and per-status counts (sparse — only statuses that occurred are present). */
export type HourlyTaskActivityBucket = {
date: Date;
total: number;
} & Partial<Record<TaskRunStatus, number>>;
/** 24h hourly stacked-by-status series keyed by task slug. */
export type HourlyTaskActivity = Record<string, HourlyTaskActivityBucket[]>;
export class UnifiedTaskListPresenter {
constructor(private readonly _replica: PrismaClientOrTransaction) {}
public async call(args: {
organizationId: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
}): Promise<{
items: UnifiedTaskListItem[];
hourlyActivity: Promise<HourlyTaskActivity>;
runningStates: Promise<UnifiedRunningStates>;
}> {
// Share the current-worker lookup across both inner presenters — without
// this they'd each do an independent Postgres round-trip for the same row.
const currentWorker = await findCurrentWorkerFromEnvironment(
{ id: args.environmentId, type: args.environmentType },
this._replica
);
const [taskResult, agentResult] = await Promise.all([
taskListPresenter.call({ ...args, currentWorker }),
agentListPresenter.call({ ...args, currentWorker }),
]);
const items = toUnifiedItems(taskResult.tasks, agentResult.agents);
const allSlugs = items.map((item) => item.slug);
const hourlyActivity: Promise<HourlyTaskActivity> =
allSlugs.length === 0
? Promise.resolve({})
: (async () => {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
args.organizationId,
"standard"
);
return getHourlyTaskActivity(clickhouse, {
organizationId: args.organizationId,
projectId: args.projectId,
environmentId: args.environmentId,
slugs: allSlugs,
});
})();
const runningStates: Promise<UnifiedRunningStates> = Promise.all([
taskResult.runningStats,
agentResult.activeStates,
]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates));
return { items, hourlyActivity, runningStates };
}
}
/** Query trigger_dev.task_runs_v2 for run counts per (hour, status) over the
* past 24h, grouped by task slug.
*
* Uses FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree;
* org/project filters engage the sort-key prefix for partition pruning. */
async function getHourlyTaskActivity(
clickhouse: ClickHouse,
args: {
organizationId: string;
projectId: string;
environmentId: string;
slugs: string[];
}
): Promise<HourlyTaskActivity> {
// Align the lower bound to the start of the hour 23h ago so the query
// returns exactly 24 distinct hour buckets, matching the JS-side key array.
// `now() - INTERVAL 24 HOUR` would let a 25th (oldest) bucket slip in for
// any request made past the top of an hour, and those runs would be
// silently dropped from the chart.
const queryFn = clickhouse.reader.query({
name: "unifiedTaskListHourlyActivity",
query: `SELECT
task_identifier,
toStartOfHour(created_at) AS bucket,
status,
count() AS val
FROM trigger_dev.task_runs_v2 FINAL
WHERE organization_id = {organizationId: String}
AND project_id = {projectId: String}
AND environment_id = {environmentId: String}
AND task_identifier IN {slugs: Array(String)}
AND created_at >= toStartOfHour(now() - INTERVAL 23 HOUR)
AND _is_deleted = 0
GROUP BY task_identifier, bucket, status
ORDER BY task_identifier, bucket, status`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
slugs: z.array(z.string()),
}),
schema: z.object({
task_identifier: z.string(),
bucket: z.string(),
status: z.string(),
val: z.coerce.number(),
}),
});
const [error, rows] = await queryFn(args);
if (error) {
console.error("Unified task list hourly activity query failed:", error);
return {};
}
// 24 hourly buckets ending at the current hour. Keys match ClickHouse's
// `toStartOfHour(created_at)` formatting ("YYYY-MM-DD HH:00:00").
const now = new Date();
const startHour = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours() - 23,
0,
0,
0
)
);
const buckets: { key: string; date: Date }[] = [];
for (let i = 0; i < 24; i++) {
const date = new Date(startHour.getTime() + i * 3_600_000);
const key = date.toISOString().slice(0, 13).replace("T", " ") + ":00:00";
buckets.push({ key, date });
}
// slug → bucketKey → bucket payload (per-status counts + total)
const slugBuckets = new Map<string, Map<string, HourlyTaskActivityBucket>>();
for (const row of rows ?? []) {
let perSlug = slugBuckets.get(row.task_identifier);
if (!perSlug) {
perSlug = new Map();
slugBuckets.set(row.task_identifier, perSlug);
}
let bucket = perSlug.get(row.bucket);
if (!bucket) {
bucket = { date: new Date(0), total: 0 };
perSlug.set(row.bucket, bucket);
}
const status = row.status as TaskRunStatus;
bucket[status] = (bucket[status] ?? 0) + row.val;
bucket.total += row.val;
}
const result: HourlyTaskActivity = {};
for (const slug of args.slugs) {
const perSlug = slugBuckets.get(slug);
result[slug] = buckets.map(({ key, date }) => {
const existing = perSlug?.get(key);
if (!existing) return { date, total: 0 };
return { ...existing, date };
});
}
return result;
}
function toUnifiedItems(
tasks: TaskListItem[],
agents: Array<{
slug: string;
filePath: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
config: unknown;
}>
): UnifiedTaskListItem[] {
const items: UnifiedTaskListItem[] = [];
for (const task of tasks) {
items.push({
kind: task.triggerSource === "SCHEDULED" ? "SCHEDULED" : "STANDARD",
slug: task.slug,
filePath: task.filePath,
triggerSource: task.triggerSource,
createdAt: task.createdAt,
});
}
for (const agent of agents) {
items.push({
kind: "AGENT",
slug: agent.slug,
filePath: agent.filePath,
triggerSource: agent.triggerSource,
createdAt: agent.createdAt,
agentType: (agent.config as { type?: string } | null)?.type,
});
}
items.sort((a, b) => a.slug.localeCompare(b.slug));
return items;
}
function mergeRunningStates(
runningStats: Record<string, { running: number; queued: number }>,
activeStates: Record<string, AgentActiveState>
): UnifiedRunningStates {
const out: UnifiedRunningStates = {};
for (const [slug, stats] of Object.entries(runningStats)) {
out[slug] = { kind: "task", running: stats?.running ?? 0 };
}
for (const [slug, state] of Object.entries(activeStates)) {
// Guard against slug collisions: a single BackgroundWorker isn't expected
// to have both a STANDARD/SCHEDULED task and an AGENT task with the same
// slug, but nothing in the schema enforces it. If it ever happened, the
// standard-task running count would be silently dropped and the row would
// get relabelled as an agent.
if (slug in out) continue;
out[slug] = {
kind: "agent",
running: state?.running ?? 0,
suspended: state?.suspended ?? 0,
};
}
return out;
}
function setupUnifiedTaskListPresenter() {
return new UnifiedTaskListPresenter($replica);
}
export const unifiedTaskListPresenter = singleton(
"unifiedTaskListPresenter",
setupUnifiedTaskListPresenter
);
@@ -0,0 +1,152 @@
import type { DataPoint } from "regression";
import { linear } from "regression";
import type { PrismaClientOrTransaction } from "~/db.server";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getUsageSeries } from "~/services/platform.v3.server";
import { createTimeSeriesData } from "~/utils/graphs";
import { BasePresenter } from "./basePresenter.server";
type Options = {
organizationId: string;
startDate: Date;
};
export type TaskUsageItem = {
taskIdentifier: string;
runCount: number;
averageDuration: number;
averageCost: number;
totalDuration: number;
totalCost: number;
totalBaseCost: number;
};
export type UsageSeriesData = {
date: string;
dollars: number;
}[];
export class UsagePresenter extends BasePresenter {
public async call({ organizationId, startDate }: Options) {
if (isNaN(startDate.getTime())) {
throw new Error("Invalid start date");
}
//month period
const startOfMonth = new Date(startDate);
startOfMonth.setUTCDate(1);
startOfMonth.setUTCHours(0, 0, 0, 0);
const endOfMonth = new Date(
startOfMonth.getFullYear(),
startOfMonth.getMonth() + 1,
0,
23,
59,
59,
999
);
//usage data from the platform
const usage = getUsageSeries(organizationId, {
from: startOfMonth,
to: endOfMonth,
window: "DAY",
}).then((data) => {
//we want to sum it to get the total usage
const current = (data?.data.reduce((acc, period) => acc + period.value, 0) ?? 0) / 100;
// Get the start day (the day the customer started using the product) or the first day of the month
const startDay = new Date(data?.data.at(0)?.windowStart ?? startOfMonth).getDate();
// We want to project so we convert the data into an array of tuples [dayNumber, value]
const projectionData =
data?.data.map((period, index) => {
// Each value should be the sum of the previous values + the current value
// Adjust the day number to start from 1 when the customer started using the product
return [
new Date(period.windowStart).getDate() - startDay + 1,
data.data.slice(0, index + 1).reduce((acc, period) => acc + period.value, 0) / 100,
] as DataPoint;
}) ?? ([] as DataPoint[]);
const result = linear(projectionData);
const [a, b] = result.equation;
// Adjust the total days in the month based on when the customer started
const totalDaysInMonth = endOfMonth.getDate() - startDay + 1;
const projected = a * totalDaysInMonth + b;
const overall = {
current,
projected,
};
//and create daily data for the graph
const timeSeries = createTimeSeriesData({
startDate: startOfMonth,
endDate: endOfMonth,
window: "DAY",
data: data
? data.data.map((period) => ({
date: new Date(period.windowStart),
value: period.value,
}))
: [],
}).map((period) => ({
date: period.date.toISOString(),
dollars: (period.value ?? 0) / 100,
}));
return {
overall,
timeSeries,
};
});
//usage by task
const tasks = await getTaskUsageByOrganization(
organizationId,
startOfMonth,
endOfMonth,
this._replica
);
return {
usage,
tasks,
};
}
}
async function getTaskUsageByOrganization(
organizationId: string,
startOfMonth: Date,
endOfMonth: Date,
replica: PrismaClientOrTransaction
) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
);
const [queryError, tasks] = await clickhouse.taskRuns.getTaskUsageByOrganization({
startTime: startOfMonth.getTime(),
endTime: endOfMonth.getTime(),
organizationId,
});
if (queryError) {
throw queryError;
}
return tasks
.map((task) => ({
taskIdentifier: task.task_identifier,
runCount: Number(task.run_count),
averageDuration: Number(task.average_duration),
averageCost: Number(task.average_cost) + env.CENTS_PER_RUN / 100,
totalDuration: Number(task.total_duration),
totalCost: Number(task.total_cost) + Number(task.total_base_cost),
}))
.sort((a, b) => b.totalCost - a.totalCost);
}
@@ -0,0 +1,743 @@
import { type PrismaClient } from "@trigger.dev/database";
import { type Result, fromPromise, ok, okAsync, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import type {
VercelCustomEnvironment,
VercelEnvironmentVariable,
} from "~/models/vercelIntegration.server";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import { type GitHubAppInstallation } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
import type { VercelProjectIntegrationData } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { BasePresenter } from "./basePresenter.server";
type VercelSettingsOptions = {
projectId: string;
organizationId: string;
};
export type VercelSettingsResult = {
enabled: boolean;
hasOrgIntegration: boolean;
authInvalid?: boolean;
authError?: string;
connectedProject?: {
id: string;
vercelProjectId: string;
vercelProjectName: string;
vercelTeamId: string | null;
integrationData: VercelProjectIntegrationData;
createdAt: Date;
};
isGitHubConnected: boolean;
hasStagingEnvironment: boolean;
hasPreviewEnvironment: boolean;
customEnvironments: VercelCustomEnvironment[];
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
autoAssignCustomDomains?: boolean | null;
/** URL to manage Vercel integration access (project sharing) on vercel.com */
vercelManageAccessUrl?: string;
/** The currently pinned TRIGGER_VERSION on Vercel production, if set. Used to surface
* the pin in the UI and prompt the user to clear it when atomic deployments are disabled. */
currentTriggerVersion?: string | null;
/** True when the Vercel lookup for TRIGGER_VERSION failed (network/auth/etc). Distinct
* from "no pin set" — the UI uses this to warn the user and still prompt them on disable
* so they can manually verify that production isn't pinned. */
currentTriggerVersionFetchFailed?: boolean;
};
export type VercelAvailableProject = {
id: string;
name: string;
};
export type VercelOnboardingData = {
customEnvironments: VercelCustomEnvironment[];
environmentVariables: VercelEnvironmentVariable[];
availableProjects: VercelAvailableProject[];
hasProjectSelected: boolean;
authInvalid?: boolean;
authError?: string;
existingVariables: Record<string, { environments: string[] }>; // Environment slugs (non-archived only)
gitHubAppInstallations: GitHubAppInstallation[];
isGitHubConnected: boolean;
isOnboardingComplete: boolean;
};
export class VercelSettingsPresenter extends BasePresenter {
/**
* Get Vercel integration settings for the settings page
*/
public async call({
projectId,
organizationId,
}: VercelSettingsOptions): Promise<Result<VercelSettingsResult, unknown>> {
const vercelIntegrationEnabled = OrgIntegrationRepository.isVercelSupported;
if (!vercelIntegrationEnabled) {
return ok({
enabled: false,
hasOrgIntegration: false,
authInvalid: false,
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
const orgIntegrationResult = await fromPromise(
(this._replica as PrismaClient).organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
}),
(error) => error
);
if (orgIntegrationResult.isErr()) {
logger.error("Unexpected error in VercelSettingsPresenter.call", {
error: orgIntegrationResult.error,
});
return ok({
enabled: true,
hasOrgIntegration: false,
authInvalid: true,
authError:
orgIntegrationResult.error instanceof Error
? orgIntegrationResult.error.message
: "Failed to fetch organization integration",
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
const orgIntegration = orgIntegrationResult.value;
const hasOrgIntegration = orgIntegration !== null;
if (hasOrgIntegration) {
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
if (tokenResult.isErr() || !tokenResult.value.isValid) {
return ok({
enabled: true,
hasOrgIntegration: true,
authInvalid: true,
authError: tokenResult.isErr() ? tokenResult.error.message : "Vercel token is invalid",
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
}
const checkOrgIntegration = () =>
fromPromise(Promise.resolve(hasOrgIntegration), (error) => ({
type: "other" as const,
cause: error,
}));
const checkGitHubConnection = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
id: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((repo) => repo !== null);
const checkStagingEnvironment = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId,
type: "STAGING",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((env) => env !== null);
const checkPreviewEnvironment = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId,
type: "PREVIEW",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((env) => env !== null);
const getVercelProjectIntegration = () =>
fromPromise(
(this._replica as PrismaClient).organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((integration) => {
if (!integration) {
return undefined;
}
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
integration.integrationData
);
if (!parsedData.success) {
return undefined;
}
return {
id: integration.id,
vercelProjectId: integration.externalEntityId,
vercelProjectName: parsedData.data.vercelProjectName,
vercelTeamId: parsedData.data.vercelTeamId,
integrationData: parsedData.data,
createdAt: integration.createdAt,
};
});
return ResultAsync.combine([
checkOrgIntegration(),
checkGitHubConnection(),
checkStagingEnvironment(),
checkPreviewEnvironment(),
getVercelProjectIntegration(),
])
.andThen(
([
hasOrgIntegration,
isGitHubConnected,
hasStagingEnvironment,
hasPreviewEnvironment,
connectedProject,
]) => {
const fetchVercelData = async (): Promise<{
customEnvironments: VercelCustomEnvironment[];
autoAssignCustomDomains: boolean | null;
vercelManageAccessUrl?: string;
currentTriggerVersion: string | null;
currentTriggerVersionFetchFailed: boolean;
}> => {
if (!orgIntegration) {
return {
customEnvironments: [],
autoAssignCustomDomains: null,
currentTriggerVersion: null,
currentTriggerVersionFetchFailed: false,
};
}
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
if (clientResult.isErr()) {
// We couldn't even build a Vercel client — treat as fetch failure so the UI
// still prompts the user when they disable atomic deployments.
return {
customEnvironments: [],
autoAssignCustomDomains: null,
currentTriggerVersion: null,
currentTriggerVersionFetchFailed: true,
};
}
const client = clientResult.value;
const teamId =
await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
// Build manage access URL
let vercelManageAccessUrl: string | undefined;
const appSlug = env.VERCEL_INTEGRATION_APP_SLUG;
const integrationData = orgIntegration.integrationData as Record<
string,
unknown
> | null;
const installationId =
typeof integrationData?.installationId === "string"
? integrationData.installationId
: undefined;
if (appSlug && installationId && teamId) {
const teamSlugResult = await VercelIntegrationRepository.getTeamSlug(client, teamId);
if (teamSlugResult.isOk()) {
vercelManageAccessUrl = `https://vercel.com/${teamSlugResult.value}/~/integrations/${appSlug}/${installationId}`;
}
}
if (!connectedProject) {
return {
customEnvironments: [],
autoAssignCustomDomains: null,
vercelManageAccessUrl,
currentTriggerVersion: null,
currentTriggerVersionFetchFailed: false,
};
}
const [customEnvsResult, autoAssignResult, triggerVersionResult] = await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
connectedProject.vercelProjectId,
teamId
),
VercelIntegrationRepository.getAutoAssignCustomDomains(
client,
connectedProject.vercelProjectId,
teamId
),
VercelIntegrationRepository.getVercelEnvironmentVariableValues(
client,
connectedProject.vercelProjectId,
teamId,
"production",
(key) => key === "TRIGGER_VERSION"
),
]);
let currentTriggerVersion: string | null = null;
let currentTriggerVersionFetchFailed = false;
if (triggerVersionResult.isOk()) {
const match = triggerVersionResult.value.find(
(envVar) => envVar.key === "TRIGGER_VERSION" && envVar.target.includes("production")
);
currentTriggerVersion = match?.value ?? null;
} else {
currentTriggerVersionFetchFailed = true;
logger.warn(
"Failed to fetch current TRIGGER_VERSION from Vercel — surfacing as unknown",
{
projectId,
vercelProjectId: connectedProject.vercelProjectId,
error: triggerVersionResult.error.message,
}
);
}
return {
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
vercelManageAccessUrl,
currentTriggerVersion,
currentTriggerVersionFetchFailed,
};
};
return fromPromise(fetchVercelData(), (error) => ({
type: "other" as const,
cause: error,
})).map(
({
customEnvironments,
autoAssignCustomDomains,
vercelManageAccessUrl,
currentTriggerVersion,
currentTriggerVersionFetchFailed,
}) =>
({
enabled: true,
hasOrgIntegration,
authInvalid: false,
connectedProject,
isGitHubConnected,
hasStagingEnvironment,
hasPreviewEnvironment,
customEnvironments,
autoAssignCustomDomains,
vercelManageAccessUrl,
currentTriggerVersion,
currentTriggerVersionFetchFailed,
}) as VercelSettingsResult
);
}
)
.mapErr((error) => {
// Log the error and return a safe fallback
logger.error("Error in VercelSettingsPresenter.call", { error });
return error;
});
}
/**
* Get data needed for the onboarding modal (custom environments and env vars)
*/
public async getOnboardingData(
projectId: string,
organizationId: string,
vercelEnvironmentId?: string
): Promise<VercelOnboardingData | null> {
const result = await ResultAsync.fromPromise(
(async (): Promise<VercelOnboardingData | null> => {
const [gitHubInstallations, connectedGitHubRepo] = await Promise.all([
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
id: true,
},
}),
]);
const isGitHubConnected = connectedGitHubRepo !== null;
const gitHubAppInstallations: GitHubAppInstallation[] = gitHubInstallations.map(
(installation) => ({
id: installation.id,
appInstallationId: installation.appInstallationId,
targetType: installation.targetType,
accountHandle: installation.accountHandle,
repositories: installation.repositories.map((repo) => ({
id: repo.id,
name: repo.name,
fullName: repo.fullName,
private: repo.private,
htmlUrl: repo.htmlUrl,
})),
})
);
const orgIntegration = await (
this._replica as PrismaClient
).organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
});
if (!orgIntegration) {
return null;
}
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
if (tokenResult.isErr() || !tokenResult.value.isValid) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: true,
authError: tokenResult.isErr() ? tokenResult.error.message : "Vercel token is invalid",
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const clientResult =
await VercelIntegrationRepository.getVercelClientAndToken(orgIntegration);
if (clientResult.isErr()) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: clientResult.error.authInvalid,
authError: clientResult.error.authInvalid ? clientResult.error.message : undefined,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const { client, accessToken } = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const projectIntegration = await (
this._replica as PrismaClient
).organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
});
const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(
client,
teamId
);
if (availableProjectsResult.isErr()) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: availableProjectsResult.error.authInvalid,
authError: availableProjectsResult.error.authInvalid
? availableProjectsResult.error.message
: undefined,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
if (!projectIntegration) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: availableProjectsResult.value,
hasProjectSelected: false,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] =
await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
projectIntegration.externalEntityId,
teamId
),
VercelIntegrationRepository.getVercelEnvironmentVariables(
client,
projectIntegration.externalEntityId,
teamId
),
// Only fetch shared env vars if teamId is available
teamId
? VercelIntegrationRepository.getVercelSharedEnvironmentVariables(
accessToken,
teamId,
projectIntegration.externalEntityId
)
: okAsync(
[] as Array<{
id: string;
key: string;
type: string;
isSecret: boolean;
target: string[];
}>
),
]);
const authInvalid =
(customEnvironmentsResult.isErr() && customEnvironmentsResult.error.authInvalid) ||
(projectEnvVarsResult.isErr() && projectEnvVarsResult.error.authInvalid) ||
(sharedEnvVarsResult.isErr() && sharedEnvVarsResult.error.authInvalid);
if (authInvalid) {
const authError =
(customEnvironmentsResult.isErr() &&
customEnvironmentsResult.error.authInvalid &&
customEnvironmentsResult.error.message) ||
(projectEnvVarsResult.isErr() &&
projectEnvVarsResult.error.authInvalid &&
projectEnvVarsResult.error.message) ||
(sharedEnvVarsResult.isErr() &&
sharedEnvVarsResult.error.authInvalid &&
sharedEnvVarsResult.error.message) ||
undefined;
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: availableProjectsResult.value,
hasProjectSelected: true,
authInvalid: true,
authError: authError || undefined,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const customEnvironments = customEnvironmentsResult.isOk()
? customEnvironmentsResult.value
: [];
const projectEnvVars = projectEnvVarsResult.isOk() ? projectEnvVarsResult.value : [];
const sharedEnvVars = sharedEnvVarsResult.isOk() ? sharedEnvVarsResult.value : [];
// Hide platform-managed reserved keys from the onboarding preview.
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
const mergedEnvVars: VercelEnvironmentVariable[] = [
...projectEnvVars
.filter((v) => !isReservedForExternalSync(v.key))
.map((v) => {
const envVar = { ...v };
if (
vercelEnvironmentId &&
(v as any).customEnvironmentIds?.includes(vercelEnvironmentId)
) {
envVar.target = [...v.target, "staging"];
}
return envVar;
}),
...sharedEnvVars
.filter((v) => !projectEnvVarKeys.has(v.key) && !isReservedForExternalSync(v.key))
.map((v) => {
const envVar = {
id: v.id,
key: v.key,
type: v.type as VercelEnvironmentVariable["type"],
isSecret: v.isSecret,
target: v.target,
isShared: true,
customEnvironmentIds: [] as string[],
};
if (
vercelEnvironmentId &&
(v as any).customEnvironmentIds?.includes(vercelEnvironmentId)
) {
envVar.target = [...v.target, "staging"];
}
return envVar;
}),
];
const sortedEnvVars = [...mergedEnvVars].sort((a, b) => a.key.localeCompare(b.key));
const projectEnvs = await (this._replica as PrismaClient).runtimeEnvironment.findMany({
where: {
projectId,
archivedAt: null, // Filter out archived environments
},
select: {
id: true,
slug: true,
type: true,
},
});
const envIdToSlug = new Map(projectEnvs.map((e) => [e.id, e.slug]));
const activeEnvIds = new Set(projectEnvs.map((e) => e.id));
const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient);
const existingVariables = await envVarRepository.getProject(projectId);
const existingVariablesRecord: Record<string, { environments: string[] }> = {};
for (const v of existingVariables) {
// Filter out archived environments and map to slugs
const activeEnvSlugs = v.values
.filter((val) => activeEnvIds.has(val.environment.id))
.map(
(val) => envIdToSlug.get(val.environment.id) || val.environment.type.toLowerCase()
);
if (activeEnvSlugs.length > 0) {
existingVariablesRecord[v.key] = {
environments: activeEnvSlugs,
};
}
}
const parsedIntegrationData = VercelProjectIntegrationDataSchema.safeParse(
projectIntegration.integrationData
);
return {
customEnvironments,
environmentVariables: sortedEnvVars,
availableProjects: availableProjectsResult.value,
hasProjectSelected: true,
existingVariables: existingVariablesRecord,
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: parsedIntegrationData.success
? (parsedIntegrationData.data.onboardingCompleted ?? false)
: false,
};
})(),
(error) => error
);
if (result.isErr()) {
logger.error("Error in getOnboardingData", { error: result.error });
return null;
}
return result.value;
}
}
@@ -0,0 +1,74 @@
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { BasePresenter } from "./basePresenter.server";
const DEFAULT_ITEMS_PER_PAGE = 25;
const MAX_ITEMS_PER_PAGE = 100;
export class VersionListPresenter extends BasePresenter {
private readonly perPage: number;
constructor(perPage: number = DEFAULT_ITEMS_PER_PAGE) {
super();
this.perPage = Math.min(perPage, MAX_ITEMS_PER_PAGE);
}
public async call({
environment,
query,
}: {
environment: Pick<RuntimeEnvironment, "id" | "type">;
query?: string;
}) {
const hasFilters = query !== undefined && query.length > 0;
const versions = await this._replica.backgroundWorker.findMany({
select: {
version: true,
},
where: {
runtimeEnvironmentId: environment.id,
version: query
? {
contains: query,
}
: undefined,
},
orderBy: {
createdAt: "desc",
},
take: this.perPage,
});
let currentVersion: string | undefined;
if (environment.type !== "DEVELOPMENT") {
const currentWorker = await this._replica.workerDeploymentPromotion.findFirst({
select: {
deployment: {
select: {
version: true,
},
},
},
where: {
environmentId: environment.id,
label: CURRENT_DEPLOYMENT_LABEL,
},
});
if (currentWorker) {
currentVersion = currentWorker.deployment.version;
}
}
return {
success: true as const,
versions: versions.map((version) => ({
version: version.version,
isCurrent: version.version === currentVersion,
})),
hasFilters,
};
}
}
@@ -0,0 +1,135 @@
import type { ScheduleObject } from "@trigger.dev/core/v3";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
import { NextRunListPresenter } from "./NextRunListPresenter.server";
import { scheduleWhereClause } from "~/models/schedules.server";
type ViewScheduleOptions = {
userId?: string;
projectId: string;
friendlyId: string;
environmentId: string;
};
export class ViewSchedulePresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId, projectId, friendlyId, environmentId }: ViewScheduleOptions) {
const schedule = await this.#prismaClient.taskSchedule.findFirst({
select: {
id: true,
type: true,
friendlyId: true,
generatorExpression: true,
generatorDescription: true,
timezone: true,
externalId: true,
deduplicationKey: true,
userProvidedDeduplicationKey: true,
taskIdentifier: true,
project: {
select: {
id: true,
organizationId: true,
},
},
instances: {
select: {
environment: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
branchName: true,
},
},
},
},
active: true,
},
where: scheduleWhereClause(projectId, friendlyId),
});
if (!schedule) {
return;
}
const nextRuns = schedule.active
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
: [];
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
schedule.project.organizationId,
"standard"
);
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouse);
const { runs } = await runPresenter.call(schedule.project.organizationId, environmentId, {
projectId: schedule.project.id,
scheduleId: schedule.id,
pageSize: 5,
period: "31d",
});
return {
schedule: {
...schedule,
timezone: schedule.timezone,
cron: schedule.generatorExpression,
cronDescription: schedule.generatorDescription,
nextRuns,
runs,
environments: schedule.instances.map((instance) => {
const environment = instance.environment;
return {
...displayableEnvironment(environment, userId),
branchName: environment.branchName ?? undefined,
};
}),
},
};
}
public toJSONResponse(result: NonNullable<Awaited<ReturnType<ViewSchedulePresenter["call"]>>>) {
const response: ScheduleObject = {
id: result.schedule.friendlyId,
type: result.schedule.type,
task: result.schedule.taskIdentifier,
active: result.schedule.active,
nextRun: result.schedule.nextRuns[0],
generator: {
type: "CRON",
expression: result.schedule.cron,
description: result.schedule.cronDescription,
},
timezone: result.schedule.timezone,
externalId: result.schedule.externalId ?? undefined,
deduplicationKey: result.schedule.userProvidedDeduplicationKey
? (result.schedule.deduplicationKey ?? undefined)
: undefined,
environments: result.schedule.instances.map((instance) => ({
id: instance.environment.id,
type: instance.environment.type,
})),
};
return response;
}
}
@@ -0,0 +1,372 @@
import parse from "parse-duration";
import {
type RunEngineVersion,
type RuntimeEnvironmentType,
type WaitpointStatus,
} from "@trigger.dev/database";
import { type Direction } from "~/components/ListPagination";
import { type PrismaClientOrTransaction } from "~/db.server";
import { BasePresenter } from "./basePresenter.server";
import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { type WaitpointTokenStatus, type WaitpointTokenItem } from "@trigger.dev/core/v3";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
const DEFAULT_PAGE_SIZE = 25;
// Row shape returned by the raw MANUAL-waitpoint keyset scan. Named so both the
// scan closure and the #scanWaitpoints store-selection helper reference one type.
type WaitpointRow = {
id: string;
friendlyId: string;
status: WaitpointStatus;
completedAt: Date | null;
completedAfter: Date | null;
outputIsError: boolean;
idempotencyKey: string;
idempotencyKeyExpiresAt: Date | null;
inactiveIdempotencyKey: string | null;
userProvidedIdempotencyKey: boolean;
createdAt: Date;
tags: null | string[];
};
export type WaitpointListOptions = {
environment: {
id: string;
type: RuntimeEnvironmentType;
project: {
id: string;
engine: RunEngineVersion;
};
apiKey: string;
};
// filters
id?: string;
statuses?: WaitpointTokenStatus[];
idempotencyKey?: string;
tags?: string[];
period?: string;
from?: number;
to?: number;
// pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
type Result =
| {
success: true;
tokens: WaitpointTokenItem[];
pagination: {
next: string | undefined;
previous: string | undefined;
};
hasFilters: boolean;
hasAnyTokens: boolean;
filters: WaitpointSearchParams;
}
| {
success: false;
code: "ENGINE_VERSION_MISMATCH" | "UNKNOWN";
error: string;
tokens: [];
pagination: {
next: undefined;
previous: undefined;
};
hasFilters: false;
hasAnyTokens: false;
filters: undefined;
};
export class WaitpointListPresenter extends BasePresenter {
// Optional run-ops read-routing. Omitted (single-DB / self-host) => every read
// goes through `_replica` exactly as today (passthrough). There is NO legacy
// writer/primary handle by construction — the legacy field is the read replica only.
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction; // new run-ops client
runOpsLegacyReplica?: PrismaClientOrTransaction; // legacy run-ops READ REPLICA only — never the legacy primary
splitEnabled?: boolean; // resolved boot constant
}
) {
super(prismaClient, replicaClient);
}
public async call({
environment,
id,
statuses,
idempotencyKey,
tags,
period,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: WaitpointListOptions): Promise<Result> {
const engineVersion = await determineEngineVersion({ environment });
if (engineVersion === "V1") {
return {
success: false,
code: "ENGINE_VERSION_MISMATCH",
error: "Upgrade to SDK version 4+ to use Waitpoint tokens.",
tokens: [],
pagination: {
next: undefined,
previous: undefined,
},
hasFilters: false,
hasAnyTokens: false,
filters: undefined,
};
}
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
id !== undefined ||
hasStatusFilters ||
idempotencyKey !== undefined ||
(tags !== undefined && tags.length > 0) ||
(period !== undefined && period !== "all") ||
from !== undefined ||
to !== undefined;
let filterOutputIsError: boolean | undefined;
//if the only status is completed: true
//if the only status is failed: false
//otherwise undefined
if (statuses?.length === 1) {
if (statuses[0] === "COMPLETED") {
filterOutputIsError = false;
} else if (statuses[0] === "TIMED_OUT") {
filterOutputIsError = true;
}
}
const statusesToFilter: WaitpointStatus[] =
statuses?.map((status) => {
switch (status) {
case "WAITING":
return "PENDING";
case "COMPLETED":
return "COMPLETED";
case "TIMED_OUT":
return "COMPLETED";
}
}) ?? [];
const periodMs = period ? parse(period) : undefined;
let createdAtGte: Date | undefined;
if (periodMs != null) {
createdAtGte = new Date(Date.now() - periodMs);
}
if (from !== undefined) {
const fromDate = new Date(from);
createdAtGte =
createdAtGte === undefined ? fromDate : fromDate > createdAtGte ? fromDate : createdAtGte;
}
const createdAtLte: Date | undefined = to !== undefined ? new Date(to) : undefined;
const tokens = await this.#scanWaitpoints(
(client) =>
client.waitpoint.findMany({
where: {
environmentId: environment.id,
type: "MANUAL",
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
...(id ? { friendlyId: id } : {}),
...(statusesToFilter.length ? { status: { in: statusesToFilter } } : {}),
...(filterOutputIsError !== undefined ? { outputIsError: filterOutputIsError } : {}),
...(idempotencyKey
? { OR: [{ idempotencyKey }, { inactiveIdempotencyKey: idempotencyKey }] }
: {}),
...(createdAtGte !== undefined || createdAtLte !== undefined
? {
createdAt: {
...(createdAtGte !== undefined ? { gte: createdAtGte } : {}),
...(createdAtLte !== undefined ? { lte: createdAtLte } : {}),
},
}
: {}),
...(tags && tags.length > 0 ? { tags: { hasSome: tags } } : {}),
},
orderBy: { id: direction === "forward" ? "desc" : "asc" },
take: pageSize + 1,
select: {
id: true,
friendlyId: true,
status: true,
completedAt: true,
completedAfter: true,
outputIsError: true,
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
userProvidedIdempotencyKey: true,
tags: true,
createdAt: true,
},
}),
pageSize,
direction
);
const hasMore = tokens.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? tokens.at(0)?.id : undefined;
if (hasMore) {
next = tokens[pageSize - 1]?.id;
}
break;
case "backward":
tokens.reverse();
if (hasMore) {
previous = tokens[1]?.id;
next = tokens[pageSize]?.id;
} else {
next = tokens[pageSize - 1]?.id;
}
break;
}
const tokensToReturn =
direction === "backward" && hasMore
? tokens.slice(1, pageSize + 1)
: tokens.slice(0, pageSize);
let hasAnyTokens = tokensToReturn.length > 0;
if (!hasAnyTokens) {
hasAnyTokens = await this.#probeAnyToken(environment.id);
}
return {
success: true,
tokens: tokensToReturn.map((token) => ({
id: token.friendlyId,
url: generateHttpCallbackUrl(token.id, environment.apiKey),
status: waitpointStatusToApiStatus(token.status, token.outputIsError),
completedAt: token.completedAt ?? undefined,
timeoutAt: token.completedAfter ?? undefined,
completedAfter: token.completedAfter ?? undefined,
idempotencyKey: token.userProvidedIdempotencyKey
? (token.inactiveIdempotencyKey ?? token.idempotencyKey)
: undefined,
idempotencyKeyExpiresAt: token.idempotencyKeyExpiresAt ?? undefined,
tags: token.tags ? token.tags.sort((a, b) => a.localeCompare(b)) : [],
createdAt: token.createdAt,
})),
pagination: {
next,
previous,
},
hasFilters,
hasAnyTokens,
filters: {
id,
statuses: statuses?.length ? statuses : undefined,
tags: tags?.length ? tags : undefined,
idempotencyKey,
period,
from,
to,
cursor,
direction,
},
};
}
// Run-ops reads for the Waitpoint-token dashboard. Split on: new DB first, then
// the LEGACY READ REPLICA ONLY for the not-yet-migrated remainder — never the
// legacy primary. Split off: one plain `_replica` read.
async #scanWaitpoints(
scan: (client: PrismaClientOrTransaction) => Promise<WaitpointRow[]>,
pageSize: number,
direction: Direction
): Promise<WaitpointRow[]> {
if (!this.readRoute?.splitEnabled) {
return scan(this._replica);
}
const overfetch = pageSize + 1;
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
// New DB filled the page => any older tokens fall on a later page; keep the
// legacy read off the hot path. Presence on the new DB is the migrated signal.
if (newRows.length >= overfetch) {
return newRows;
}
// READ REPLICA handle only (there is no writer/primary field on readRoute).
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? this._replica);
// Merge under keyset order: de-dupe by id keeping the new-DB copy as
// authoritative, re-sort in the page's direction, re-apply the over-fetch
// window so the result matches a single union scan.
const byId = new Map<string, WaitpointRow>();
for (const row of newRows) {
byId.set(row.id, row);
}
for (const row of legacyRows) {
if (!byId.has(row.id)) {
byId.set(row.id, row);
}
}
const merged = Array.from(byId.values());
merged.sort((a, b) =>
direction === "forward" ? compareIdDesc(a.id, b.id) : compareIdAsc(a.id, b.id)
);
return merged.slice(0, overfetch);
}
// Empty-state probe: two-handle existence check (no single runId, so not
// readThroughRun). New DB first, then the LEGACY read replica in split mode so
// the empty-state never reports false-empty during migration.
async #probeAnyToken(environmentId: string): Promise<boolean> {
const onNew = await (this.readRoute?.runOpsNew ?? this._replica).waitpoint.findFirst({
where: { environmentId, type: "MANUAL" },
});
if (onNew) return true;
if (!this.readRoute?.splitEnabled) return false;
const onLegacy = await (
this.readRoute.runOpsLegacyReplica ?? this._replica
).waitpoint.findFirst({
where: { environmentId, type: "MANUAL" },
});
return Boolean(onLegacy);
}
}
function compareIdAsc(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function compareIdDesc(a: string, b: string): number {
return a < b ? 1 : a > b ? -1 : 0;
}
export function waitpointStatusToApiStatus(
status: WaitpointStatus,
outputIsError: boolean
): WaitpointTokenStatus {
switch (status) {
case "PENDING":
return "WAITING";
case "COMPLETED":
return outputIsError ? "TIMED_OUT" : "COMPLETED";
}
}
@@ -0,0 +1,225 @@
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
import { logger } from "~/services/logger.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
import { BasePresenter } from "./basePresenter.server";
import { NextRunListPresenter, type NextRunListItem } from "./NextRunListPresenter.server";
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
export class WaitpointPresenter extends BasePresenter {
constructor(
prisma?: PrismaClientOrTransaction,
replica?: PrismaClientOrTransaction,
private readonly readThroughDeps?: {
// The new run-ops client + the legacy run-ops read replica (never the legacy writer).
// Omitted => single-DB / self-host: both default to `_replica` (passthrough).
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
// Resolved boot constant from isSplitEnabled(). When false/absent:
// the waitpoint lookup is one plain findFirst and the connected-runs hydrate runs passthrough.
splitEnabled?: boolean;
}
) {
super(prisma, replica);
}
async #findWaitpoint(friendlyId: string, environmentId: string) {
const where = { friendlyId, environmentId };
const select = {
id: true,
friendlyId: true,
type: true,
status: true,
idempotencyKey: true,
userProvidedIdempotencyKey: true,
idempotencyKeyExpiresAt: true,
inactiveIdempotencyKey: true,
output: true,
outputType: true,
outputIsError: true,
completedAfter: true,
completedAt: true,
createdAt: true,
tags: true,
environmentId: true,
} as const;
const hydrate = (client: PrismaReplicaClient) => client.waitpoint.findFirst({ where, select });
if (!this.readThroughDeps) {
return this._replica.waitpoint.findFirst({ where, select });
}
const result = await readThroughRun({
runId: friendlyId,
environmentId,
readNew: (client) => hydrate(client),
readLegacy: (replica) => hydrate(replica),
deps: {
splitEnabled: this.readThroughDeps.splitEnabled,
newClient:
(this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ??
(this._replica as unknown as PrismaReplicaClient),
legacyReplica:
(this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ??
(this._replica as unknown as PrismaReplicaClient),
},
});
return result.source === "new" || result.source === "legacy-replica" ? result.value : null;
}
// Connected-run friendlyIds gathered across BOTH stores. The run<->waitpoint join co-locates with
// the RUN (written on the run's DB), so the waitpoint's own store misses a cross-DB connection; we
// read the join on each client and resolve the run's friendlyId on that same client, then union.
// We never relation-select `connectedRuns`: it is not a field on the dedicated subset `Waitpoint`.
async #connectedRunFriendlyIds(waitpointId: string): Promise<string[]> {
const replica = this._replica as unknown as PrismaReplicaClient;
const rawClients: PrismaReplicaClient[] =
this.readThroughDeps?.splitEnabled === true
? [
(this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ?? replica,
(this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ?? replica,
]
: [replica];
const clients = [...new Set(rawClients)];
const friendlyIds = new Set<string>();
for (const client of clients) {
const runIds = await this.#connectedRunIdsOn(client, waitpointId);
if (runIds.length === 0) {
continue;
}
const runs = await client.taskRun.findMany({
where: { id: { in: runIds } },
select: { friendlyId: true },
take: 5,
});
for (const run of runs) {
friendlyIds.add(run.friendlyId);
}
if (friendlyIds.size >= 5) {
break;
}
}
return Array.from(friendlyIds).slice(0, 5);
}
// Schema-aware read of the run ids linked to a waitpoint: the dedicated subset uses the explicit
// `WaitpointRunConnection` model, the control-plane full schema the implicit `_WaitpointRunConnections`
// M2M (A = TaskRun.id, B = Waitpoint.id). The dedicated join delegate is absent on the full client.
async #connectedRunIdsOn(client: PrismaReplicaClient, waitpointId: string): Promise<string[]> {
const joinDelegate = (
client as unknown as {
waitpointRunConnection?: {
findMany: (args: unknown) => Promise<{ taskRunId: string }[]>;
};
}
).waitpointRunConnection;
if (joinDelegate && typeof joinDelegate.findMany === "function") {
const links = await joinDelegate.findMany({
where: { waitpointId },
select: { taskRunId: true },
});
return links.map((link) => link.taskRunId);
}
const rows = await client.$queryRaw<{ A: string }[]>`
SELECT "A" FROM "_WaitpointRunConnections" WHERE "B" = ${waitpointId}
`;
return rows.map((row) => row.A);
}
public async call({
friendlyId,
environmentId,
projectId,
}: {
friendlyId: string;
environmentId: string;
projectId: string;
}) {
const waitpoint = await this.#findWaitpoint(friendlyId, environmentId);
if (!waitpoint) {
logger.error(`WaitpointPresenter: Waitpoint not found`, {
friendlyId,
});
return null;
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(waitpoint.environmentId);
if (!environment) {
logger.error(`WaitpointPresenter: environment not found`, { friendlyId });
return null;
}
const output =
waitpoint.outputType === "application/store"
? `/resources/packets/${environmentId}/${waitpoint.output}`
: typeof waitpoint.output !== "undefined" && waitpoint.output !== null
? await prettyPrintPacket(waitpoint.output, waitpoint.outputType ?? undefined)
: undefined;
let _isTimeout = false;
if (waitpoint.outputIsError && output) {
if (isWaitpointOutputTimeout(output)) {
_isTimeout = true;
}
}
const connectedRunIds = await this.#connectedRunFriendlyIds(waitpoint.id);
const connectedRuns: NextRunListItem[] = [];
if (connectedRunIds.length > 0) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
environment.organizationId,
"standard"
);
const runPresenter = new NextRunListPresenter(
this._prisma,
clickhouse,
this.readThroughDeps
? {
newClient: this.readThroughDeps.newClient ?? this._replica,
legacyReplica: this.readThroughDeps.legacyReplica ?? this._replica,
splitEnabled: this.readThroughDeps.splitEnabled ?? false,
}
: undefined
);
const { runs } = await runPresenter.call(environment.organizationId, environmentId, {
projectId: projectId,
runId: connectedRunIds,
pageSize: 5,
period: "31d",
});
connectedRuns.push(...runs);
}
return {
id: waitpoint.friendlyId,
type: waitpoint.type,
url: generateHttpCallbackUrl(waitpoint.id, environment.apiKey),
status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError),
idempotencyKey: waitpoint.idempotencyKey,
userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey,
idempotencyKeyExpiresAt: waitpoint.idempotencyKeyExpiresAt,
inactiveIdempotencyKey: waitpoint.inactiveIdempotencyKey,
output: output,
outputType: waitpoint.outputType,
outputIsError: waitpoint.outputIsError,
timeoutAt: waitpoint.completedAfter,
completedAfter: waitpoint.completedAfter,
completedAt: waitpoint.completedAt,
createdAt: waitpoint.createdAt,
tags: waitpoint.tags,
connectedRuns,
};
}
}
@@ -0,0 +1,109 @@
import { type PrismaClientOrTransaction } from "~/db.server";
import { BasePresenter } from "./basePresenter.server";
export type TagListOptions = {
environmentId: string;
name?: string;
//pagination
page?: number;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type TagList = Awaited<ReturnType<WaitpointTagListPresenter["call"]>>;
export type TagListItem = TagList["tags"][number];
type WaitpointTagRow = {
id: string;
name: string;
};
type TagFindManyArgs = NonNullable<
Parameters<PrismaClientOrTransaction["waitpointTag"]["findMany"]>[0]
>;
type TagQuery = {
where: TagFindManyArgs["where"];
orderBy: TagFindManyArgs["orderBy"];
};
export class WaitpointTagListPresenter extends BasePresenter {
constructor(
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
private readonly readRoute?: {
runOpsNew?: PrismaClientOrTransaction;
runOpsLegacyReplica?: PrismaClientOrTransaction; // READ REPLICA only — never the legacy primary
splitEnabled?: boolean;
}
) {
super(prismaClient, replicaClient);
}
public async call({
environmentId,
name,
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
}: TagListOptions) {
const hasFilters = Boolean(name?.trim());
const skip = (page - 1) * pageSize;
const query: TagQuery = {
where: {
environmentId,
name: name ? { startsWith: name, mode: "insensitive" } : undefined,
},
orderBy: { id: "desc" },
};
const tags = await this.#scanTags(query, skip, pageSize);
return {
tags: tags
.map((tag) => ({
name: tag.name,
}))
.slice(0, pageSize),
currentPage: page,
hasMore: tags.length > pageSize,
hasFilters,
};
}
async #scanTags(query: TagQuery, skip: number, pageSize: number): Promise<WaitpointTagRow[]> {
const scan = (client: PrismaClientOrTransaction, take: number, offset: number) =>
client.waitpointTag.findMany({ ...query, take, skip: offset });
if (!this.readRoute?.splitEnabled) {
return scan(this._replica, pageSize + 1, skip);
}
const prefixSize = skip + pageSize + 1;
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica, prefixSize, 0);
// New DB filled the prefix => any older tags fall on a later page; skip the
// legacy read entirely. Presence on the new DB is the migrated signal.
if (newRows.length >= prefixSize) {
return newRows.slice(skip, prefixSize);
}
const legacyRows = await scan(
this.readRoute.runOpsLegacyReplica ?? this._replica,
prefixSize,
0
);
const byId = new Map<string, WaitpointTagRow>();
for (const row of newRows) byId.set(row.id, row);
for (const row of legacyRows) {
if (!byId.has(row.id)) byId.set(row.id, row);
}
const merged = Array.from(byId.values());
merged.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
return merged.slice(skip, skip + pageSize + 1);
}
}
@@ -0,0 +1,157 @@
/** Shared bucketing + zero-fill helpers for the task/agent activity bar charts. */
// Snap bucket intervals to human-friendly values (1s…7d) so tick boundaries stay readable.
const NICE_BUCKET_SECONDS = [
1, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800,
604800,
] as const;
export type ChooseBucketOptions = {
/** Bucket count to aim for (a "full"-looking chart). */
targetBuckets?: number;
/** Hard ceiling so we never emit sub-pixel bars or huge result sets. */
maxBuckets?: number;
};
/**
* Pick a bucket interval (seconds): the nice value whose bucket count is closest
* to `targetBuckets` without exceeding `maxBuckets`. Keeps a 5-minute range from
* collapsing to a single 1-hour bar.
*/
export function chooseBucketSeconds(
rangeMs: number,
{ targetBuckets = 72, maxBuckets = 120 }: ChooseBucketOptions = {}
): number {
const rangeSeconds = Math.max(1, Math.ceil(rangeMs / 1000));
let best: number | null = null;
let bestScore = Infinity;
for (const secs of NICE_BUCKET_SECONDS) {
const count = rangeSeconds / secs;
if (count > maxBuckets) continue;
const score = Math.abs(count - targetBuckets);
if (score < bestScore) {
bestScore = score;
best = secs;
}
}
// Range larger than the ladder: derive an interval that respects the ceiling.
if (best === null) {
return Math.ceil(rangeSeconds / maxBuckets);
}
return best;
}
export const RUN_STATUS_GROUPS = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const;
export type RunStatusGroup = (typeof RUN_STATUS_GROUPS)[number];
const TERMINAL_GROUPS: Record<RunStatusGroup, readonly string[]> = {
COMPLETED: ["COMPLETED_SUCCESSFULLY"],
FAILED: ["COMPLETED_WITH_ERRORS", "SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "TIMED_OUT"],
CANCELED: ["CANCELED", "EXPIRED"],
RUNNING: [
"EXECUTING",
"DEQUEUED",
"PENDING_EXECUTING",
"WAITING_TO_RESUME",
"QUEUED_EXECUTING",
"PENDING",
"PENDING_VERSION",
"DELAYED",
"WAITING_FOR_DEPLOY",
],
};
/** Map a raw TaskRun status to one of the four chart groups. */
export function groupRunStatus(status: string): RunStatusGroup | undefined {
for (const label of RUN_STATUS_GROUPS) {
if (TERMINAL_GROUPS[label].includes(status)) return label;
}
return undefined;
}
export type ActivitySeriesPoint = { bucket: number } & Record<string, number>;
function bucketBounds(from: Date, to: Date, bucketSeconds: number) {
const bucketMs = bucketSeconds * 1000;
return {
bucketMs,
start: Math.floor(from.getTime() / bucketMs) * bucketMs,
end: Math.ceil(to.getTime() / bucketMs) * bucketMs,
};
}
/**
* Zero-filled grouped series: every bucket in [from, to) is emitted and every
* `orderedKeys` entry is present on each point, for contiguous bars and a stable
* legend.
*/
export function zeroFillGroupedSeries<K extends string>({
rows,
from,
to,
bucketSeconds,
orderedKeys,
groupFn,
fallbackKey,
}: {
rows: Array<{ bucket: number; status: string; val: number }>;
from: Date;
to: Date;
bucketSeconds: number;
orderedKeys: readonly K[];
/** Maps a raw status to a key; defaults to identity. */
groupFn?: (status: string) => K | undefined;
/** Key for statuses groupFn doesn't map (e.g. unknown statuses). */
fallbackKey?: K;
}): ActivitySeriesPoint[] {
const bucketMap = new Map<number, Record<string, number>>();
for (const row of rows) {
const key = (groupFn ? groupFn(row.status) : (row.status as K)) ?? fallbackKey;
if (!key) continue;
const ts = row.bucket * 1000;
const existing = bucketMap.get(ts) ?? {};
existing[key] = (existing[key] ?? 0) + row.val;
bucketMap.set(ts, existing);
}
const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds);
const points: ActivitySeriesPoint[] = [];
for (let ts = start; ts < end; ts += bucketMs) {
const existing = bucketMap.get(ts) ?? {};
const point: ActivitySeriesPoint = { bucket: ts };
for (const k of orderedKeys) point[k] = existing[k] ?? 0;
points.push(point);
}
return points;
}
/** Zero-filled single-series (scalar) time series. */
export function zeroFillScalarSeries({
rows,
from,
to,
bucketSeconds,
seriesKey,
}: {
rows: Array<{ bucket: number; val: number }>;
from: Date;
to: Date;
bucketSeconds: number;
seriesKey: string;
}): ActivitySeriesPoint[] {
const bucketMap = new Map<number, number>();
for (const row of rows) {
const ts = row.bucket * 1000;
bucketMap.set(ts, (bucketMap.get(ts) ?? 0) + row.val);
}
const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds);
const points: ActivitySeriesPoint[] = [];
for (let ts = start; ts < end; ts += bucketMs) {
points.push({ bucket: ts, [seriesKey]: bucketMap.get(ts) ?? 0 });
}
return points;
}
@@ -0,0 +1,61 @@
import type { Span } from "@opentelemetry/api";
import { SpanKind } from "@opentelemetry/api";
import type { PrismaClientOrTransaction } from "~/db.server";
import { $replica, prisma } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { attributesFromAuthenticatedEnv, tracer } from "../../v3/tracer.server";
export abstract class BasePresenter {
constructor(
protected readonly _prisma: PrismaClientOrTransaction = prisma,
protected readonly _replica: PrismaClientOrTransaction = $replica
) {}
protected async traceWithEnv<T>(
trace: string,
env: AuthenticatedEnvironment,
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(
`${this.constructor.name}.${trace}`,
{ attributes: attributesFromAuthenticatedEnv(env), kind: SpanKind.SERVER },
async (span) => {
try {
return await fn(span);
} catch (e) {
if (e instanceof Error) {
span.recordException(e);
} else {
span.recordException(new Error(String(e)));
}
throw e;
} finally {
span.end();
}
}
);
}
protected async trace<T>(trace: string, fn: (span: Span) => Promise<T>): Promise<T> {
return tracer.startActiveSpan(
`${this.constructor.name}.${trace}`,
{ kind: SpanKind.SERVER },
async (span) => {
try {
return await fn(span);
} catch (e) {
if (e instanceof Error) {
span.recordException(e);
} else {
span.recordException(new Error(String(e)));
}
throw e;
} finally {
span.end();
}
}
);
}
}
@@ -0,0 +1,78 @@
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { type PrismaReplicaClient } from "~/db.server";
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
export type EnvironmentVariablesEnvironment = {
id: string;
type: RuntimeEnvironmentType;
isBranchableEnvironment: boolean;
branchName: string | null;
parentEnvironmentId: string | null;
};
export type EnvironmentVariablesEnvironmentsResult = {
environments: EnvironmentVariablesEnvironment[];
hasStaging: boolean;
};
export async function loadEnvironmentVariablesEnvironments(
prismaClient: PrismaReplicaClient,
{ userId, projectId }: { userId: string; projectId: string },
options?: { skipProjectAccessCheck?: boolean }
): Promise<EnvironmentVariablesEnvironmentsResult> {
if (!options?.skipProjectAccessCheck) {
const project = await prismaClient.project.findFirst({
select: {
id: true,
},
where: {
id: projectId,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
throw new Error("Project not found");
}
}
const environments = await prismaClient.runtimeEnvironment.findMany({
select: {
id: true,
type: true,
isBranchableEnvironment: true,
branchName: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
},
},
},
where: {
projectId,
archivedAt: null,
},
});
const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter(
(environment) => environment.orgMember?.userId === userId || environment.orgMember === null
);
return {
environments: sortedEnvironments.map((environment) => ({
id: environment.id,
type: environment.type,
isBranchableEnvironment: environment.isBranchableEnvironment,
branchName: environment.branchName,
parentEnvironmentId: environment.parentEnvironmentId,
})),
hasStaging: environments.some((environment) => environment.type === "STAGING"),
};
}
@@ -0,0 +1,23 @@
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
import type { ListedRun } from "~/services/runsRepository/runsRepository.server";
export function mapRunToLiveFields(run: ListedRun) {
const hasFinished = isFinalRunStatus(run.status);
const startedAt = run.startedAt ?? run.lockedAt;
return {
friendlyId: run.friendlyId,
status: run.status,
updatedAt: run.updatedAt.toISOString(),
startedAt: startedAt?.toISOString(),
finishedAt: hasFinished
? (run.completedAt?.toISOString() ?? run.updatedAt.toISOString())
: undefined,
hasFinished,
isCancellable: isCancellableRunStatus(run.status),
isPending: isPendingRunStatus(run.status),
usageDurationMs: Number(run.usageDurationMs),
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
};
}
@@ -0,0 +1,43 @@
export type QueueListFilteredPagination = {
mode: "filtered";
currentPage: number;
hasMore: boolean;
};
export type QueueListUnfilteredPagination = {
mode: "unfiltered";
currentPage: number;
totalPages: number;
count: number;
};
export type QueueListPagination = QueueListFilteredPagination | QueueListUnfilteredPagination;
export type OffsetLimitPagination = {
currentPage: number;
totalPages: number;
count: number;
};
/** Maps presenter pagination to the public API / SDK offset-limit contract. */
export function toOffsetLimitQueueListPagination(
pagination: QueueListPagination,
options: { itemsOnPage: number; perPage: number }
): OffsetLimitPagination {
if (pagination.mode === "unfiltered") {
return {
currentPage: pagination.currentPage,
totalPages: pagination.totalPages,
count: pagination.count,
};
}
return {
currentPage: pagination.currentPage,
totalPages: pagination.hasMore ? pagination.currentPage + 1 : pagination.currentPage,
count:
(pagination.currentPage - 1) * options.perPage +
options.itemsOnPage +
(pagination.hasMore ? 1 : 0),
};
}