378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|