Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
2026-07-13 13:32:57 +08:00

556 lines
19 KiB
TypeScript

import { parseWithZod } from "@conform-to/zod";
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { type EnvironmentType, prettyPrintPacket } from "@trigger.dev/core/v3";
import { type Prisma } from "@trigger.dev/database";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { sortEnvironments } from "~/utils/environmentSort";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
import {
buildSyntheticReplayTaskRun,
type SyntheticReplayTaskRun,
} from "~/v3/mollifier/syntheticReplayTaskRun.server";
import parseDuration from "parse-duration";
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { queueTypeFromType } from "~/presenters/v3/QueueRetrievePresenter.server";
import { ReplayRunData } from "~/v3/replayTask";
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
const ParamSchema = z.object({
runParam: z.string(),
});
const QuerySchema = z.object({
environmentIdOverride: z.string().optional(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const user = await requireUser(request);
const userId = user.id;
const { runParam } = ParamSchema.parse(params);
const { environmentIdOverride } = QuerySchema.parse(
Object.fromEntries(new URL(request.url).searchParams)
);
// Run-ops read keyed by friendlyId only; project-scope + membership auth is
// resolved on the control plane below, keyed off the resolved run's projectId.
let run = await runStore.findRun(
{ friendlyId: runParam },
{
select: {
payload: true,
payloadType: true,
seedMetadata: true,
seedMetadataType: true,
runtimeEnvironmentId: true,
projectId: true,
concurrencyKey: true,
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
workerQueue: true,
region: true,
ttl: true,
idempotencyKey: true,
runTags: true,
queue: true,
taskIdentifier: true,
},
}
);
// project.environments is a project-rooted list read (not a single-env thread),
// so it stays a control-plane query keyed off the resolved run's projectId.
type ProjectWithEnvironments = NonNullable<Awaited<ReturnType<typeof loadProjectEnvironments>>>;
let projectEnvironments: ProjectWithEnvironments["environments"];
let projectSlug: string;
let _synthetic:
| (Awaited<ReturnType<typeof findRunByIdWithMollifierFallback>> & { __synth: true })
| undefined;
if (!run) {
// Buffered fallback: read the snapshot and look up the env list via
// the snapshot's organizationId. Without this the Replay dialog
// 404s for runs queued in the mollifier buffer, which dumps the
// user back to the task list.
const buffer = getMollifierBuffer();
const entry = buffer ? await buffer.getEntry(runParam) : null;
if (!entry) throw new Response("Not Found", { status: 404 });
const member = await prisma.orgMember.findFirst({
where: { userId, organizationId: entry.orgId },
select: { id: true },
});
if (!member) throw new Response("Not Found", { status: 404 });
const buffered = await findRunByIdWithMollifierFallback({
runId: runParam,
environmentId: entry.envId,
organizationId: entry.orgId,
});
if (!buffered) throw new Response("Not Found", { status: 404 });
_synthetic = Object.assign(buffered, { __synth: true as const });
// Pin the project lookup to the buffer entry's org (not just the env id) so a
// malformed entry whose envId resolves to a different org can't leak that
// project's data. Mirrors the PG path's org-membership scoping.
const orgProject = await loadProjectEnvironments(
{ organizationId: entry.orgId, environments: { some: { id: entry.envId } } },
userId
);
if (!orgProject) throw new Response("Not Found", { status: 404 });
run = {
payload: buffered.payload,
payloadType: buffered.payloadType ?? "application/json",
seedMetadata: buffered.seedMetadata ?? null,
seedMetadataType: buffered.seedMetadataType ?? null,
runtimeEnvironmentId: entry.envId,
projectId: "",
concurrencyKey: buffered.concurrencyKey ?? null,
maxAttempts: buffered.maxAttempts ?? null,
maxDurationInSeconds: buffered.maxDurationInSeconds ?? null,
machinePreset: buffered.machinePreset ?? null,
workerQueue: buffered.workerQueue ?? null,
region: buffered.region ?? null,
ttl: buffered.ttl ?? null,
idempotencyKey: buffered.idempotencyKey ?? null,
runTags: buffered.runTags,
queue: buffered.queue ?? "task/",
taskIdentifier: buffered.taskIdentifier ?? "",
} as unknown as typeof run;
projectEnvironments = orgProject.environments;
projectSlug = orgProject.slug;
} else {
// PG path: the run resolved from the run-ops store; fetch the env list from
// the control plane via projectId, re-applying the org-membership gate that
// used to live on the run-store join so a miss stays a 404.
const project = await loadProjectEnvironments(
{ id: run.projectId, organization: { members: { some: { userId } } } },
userId
);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
projectEnvironments = project.environments;
projectSlug = project.slug;
}
if (!run) {
throw new Response("Not Found", { status: 404 });
}
const runEnvironment = projectEnvironments.find((env) => env.id === run.runtimeEnvironmentId);
const environmentOverride = projectEnvironments.find((env) => env.id === environmentIdOverride);
const environment = environmentOverride ?? runEnvironment;
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const [taskQueue, backgroundWorkers] = await Promise.all([
findTaskQueue(environment, run.taskIdentifier),
listLatestBackgroundWorkers(environment),
]);
const latestVersions = backgroundWorkers.map((v) => v.version);
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers.at(0)?.engine === "V1";
const [payload, regionsResult] = await Promise.all([
prettyPrintPacket(run.payload, run.payloadType),
new RegionsPresenter().call({
userId,
projectSlug,
isAdmin: user.admin || user.isImpersonating,
}),
]);
return typedjson({
concurrencyKey: run.concurrencyKey,
maxAttempts: run.maxAttempts,
maxDurationSeconds: run.maxDurationInSeconds,
machinePreset: run.machinePreset,
region:
environment.type === "DEVELOPMENT"
? undefined
: regionForDisplay(run.region, run.workerQueue),
regions: regionsResult.regions,
ttlSeconds: run.ttl ? (parseDuration(run.ttl, "s") ?? undefined) : undefined,
idempotencyKey: run.idempotencyKey,
runTags: run.runTags,
payload,
payloadType: run.payloadType,
queue: run.queue,
metadata: run.seedMetadata
? await prettyPrintPacket(run.seedMetadata, run.seedMetadataType)
: undefined,
defaultTaskQueue: taskQueue
? {
id: taskQueue.friendlyId,
name: taskQueue.name.replace(/^task\//, ""),
type: queueTypeFromType(taskQueue.type),
paused: taskQueue.paused,
}
: undefined,
latestVersions,
disableVersionSelection,
allowArbitraryQueues,
environment: {
...displayableEnvironment(environment, userId),
branchName: environment.branchName ?? undefined,
},
environments: sortEnvironments(
projectEnvironments
.filter((env) => env.type !== "PREVIEW" || env.parentEnvironmentId !== null)
.map((env) => ({
...displayableEnvironment(env, userId),
branchName: env.branchName ?? undefined,
}))
),
});
}
// Project-rooted control-plane list read of the replay env dropdown.
function loadProjectEnvironments(where: Prisma.ProjectWhereInput, userId: string) {
return $replica.project.findFirst({
where,
select: {
slug: true,
environments: {
select: {
id: true,
type: true,
slug: true,
branchName: true,
parentEnvironmentId: true,
orgMember: {
select: {
user: true,
},
},
},
where: {
archivedAt: null,
OR: [
{
type: {
in: ["PREVIEW", "STAGING", "PRODUCTION"],
},
},
{
type: "DEVELOPMENT",
orgMember: {
userId,
},
},
],
},
},
},
});
}
// Resolve the run's organization so the RBAC auth scope can resolve the
// user's role in it. The run may not be in Postgres yet (buffered during a
// burst), so fall back to the buffer entry's org.
async function resolveRunOrganizationId(runParam: string): Promise<string | null> {
// Run-ops read keyed by friendlyId only; the store routes to the owning DB by
// residency off its own replica. Forwarding a control-plane client here would
// override that routing and miss any run that lives in the run-ops DB.
const run = await runStore.findRun(
{ friendlyId: runParam },
{ select: { runtimeEnvironmentId: true } }
);
if (run) {
const env = await controlPlaneResolver.resolveEnv(run.runtimeEnvironmentId);
if (env?.organizationId) {
return env.organizationId;
}
}
const buffer = getMollifierBuffer();
const entry = buffer ? await buffer.getEntry(runParam) : null;
if (entry?.orgId) {
return entry.orgId;
}
// Replica lag with the buffer entry already drained: the run can exist in the
// primary while both lookups above miss. Fall back to the primary so the RBAC
// scope is never resolved without an org (which would let the role check run
// unscoped under the RBAC plugin). Keyed by friendlyId so routing still applies.
const primaryRun = await runStore.findRun(
{ friendlyId: runParam },
{ select: { runtimeEnvironmentId: true } },
prisma
);
if (!primaryRun) {
return null;
}
const primaryEnv = await controlPlaneResolver.resolveEnv(primaryRun.runtimeEnvironmentId);
return primaryEnv?.organizationId ?? null;
}
export const action = dashboardAction(
{
params: ParamSchema,
context: async (params) => {
const organizationId = await resolveRunOrganizationId(params.runParam);
return organizationId ? { organizationId } : {};
},
authorization: { action: "write", resource: { type: "runs" } },
},
// The PG findFirst below keeps the org-membership filter so a user can't
// replay another org's run, and the buffered fallback verifies membership
// via orgMember.findFirst against the snapshot's orgId.
async ({ request, params, user }) => {
const { runParam } = params;
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: ReplayRunData });
if (submission.status !== "success") {
return json(submission.reply());
}
try {
// Run-ops read keyed by friendlyId only; membership auth is re-checked on the
// control plane below, keyed off the resolved run's projectId.
const pgRun = await runStore.findRun({ friendlyId: runParam });
// Mollifier read-fallback: if the original isn't in PG yet, synthesise a
// TaskRun from the buffered snapshot. Needs project/org/env slugs for the
// redirect path, looked up via the snapshot's runtimeEnvironmentId.
let taskRun: SyntheticReplayTaskRun | null = null;
if (pgRun) {
// Cross-org replay guard the where-clause join used to enforce; a miss falls
// through to the buffered/not-found path below.
const authorizedProject = await prisma.project.findFirst({
where: { id: pgRun.projectId, organization: { members: { some: { userId: user.id } } } },
select: { id: true },
});
const pgEnv = authorizedProject
? await controlPlaneResolver.resolveAuthenticatedEnv(pgRun.runtimeEnvironmentId)
: null;
if (pgEnv) {
taskRun = {
...pgRun,
project: {
slug: pgEnv.project.slug,
organization: { slug: pgEnv.organization.slug },
},
runtimeEnvironment: { slug: pgEnv.slug },
};
}
}
if (!taskRun) {
const buffer = getMollifierBuffer();
const entry = buffer ? await buffer.getEntry(runParam) : null;
if (entry) {
// Same org-membership gate as the PG path above. Without this
// any authenticated user who knows a runId could replay the
// buffered run across orgs.
const member = await prisma.orgMember.findFirst({
where: { userId: user.id, organizationId: entry.orgId },
select: { id: true },
});
if (!member) {
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
"Run not found"
);
}
const synthetic = await findRunByIdWithMollifierFallback({
runId: runParam,
environmentId: entry.envId,
organizationId: entry.orgId,
});
if (synthetic) {
const envRow = await prisma.runtimeEnvironment.findFirst({
// Pin to the buffer entry's org so a malformed entry can't
// resolve an environment in a different org.
where: { id: entry.envId, project: { organizationId: entry.orgId } },
select: {
slug: true,
project: { select: { slug: true, organization: { select: { slug: true } } } },
},
});
if (envRow) {
taskRun = buildSyntheticReplayTaskRun({ synthetic, envRow });
}
}
}
}
if (!taskRun) {
return redirectWithErrorMessage(submission.value.failedRedirect, request, "Run not found");
}
// A replay can target a different environment, but only within the source
// run's own project. The override id is user-supplied and the downstream
// service looks it up without scoping, so confirm it belongs to this
// project before triggering, otherwise a run could be created in another
// tenant's environment.
if (submission.value.environment) {
const overrideEnvironment = await prisma.runtimeEnvironment.findFirst({
where: {
id: submission.value.environment,
project: {
slug: taskRun.project.slug,
organization: { slug: taskRun.project.organization.slug },
},
},
select: { id: true },
});
if (!overrideEnvironment) {
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
"Environment not found"
);
}
}
const replayRunService = new ReplayTaskRunService();
const newRun = await replayRunService.call(taskRun, {
environmentId: submission.value.environment,
payload: submission.value.payload,
metadata: submission.value.metadata,
tags: submission.value.tags,
queue: submission.value.queue,
concurrencyKey: submission.value.concurrencyKey,
maxAttempts: submission.value.maxAttempts,
maxDurationSeconds: submission.value.maxDurationSeconds,
machine: submission.value.machine,
region: submission.value.region,
delaySeconds: submission.value.delaySeconds,
idempotencyKey: submission.value.idempotencyKey,
idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
ttlSeconds: submission.value.ttlSeconds,
version: submission.value.version,
prioritySeconds: submission.value.prioritySeconds,
triggerSource: "dashboard",
});
if (!newRun) {
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
"Failed to replay run"
);
}
const runPath = v3RunSpanPath(
{
slug: taskRun.project.organization.slug,
},
{ slug: taskRun.project.slug },
{ slug: taskRun.runtimeEnvironment.slug },
{ friendlyId: newRun.friendlyId },
{ spanId: newRun.spanId }
);
logger.debug("Replayed run", {
taskRunId: taskRun.id,
taskRunFriendlyId: taskRun.friendlyId,
newRunId: newRun.id,
newRunFriendlyId: newRun.friendlyId,
runPath,
});
return redirectWithSuccessMessage(runPath, request, `Replaying run`);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to replay run", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
}
logger.error("Failed to replay run", { error });
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
JSON.stringify(error)
);
}
}
);
async function findTask(
environment: { type: EnvironmentType; id: string },
taskIdentifier: string
) {
if (environment.type === "DEVELOPMENT") {
return $replica.backgroundWorkerTask.findFirst({
select: {
queueId: true,
},
where: {
slug: taskIdentifier,
runtimeEnvironmentId: environment.id,
},
orderBy: {
createdAt: "desc",
},
});
}
const currentDeployment = await findCurrentWorkerDeployment({
environmentId: environment.id,
});
return currentDeployment?.worker?.tasks.find((t) => t.slug === taskIdentifier);
}
async function findTaskQueue(
environment: { type: EnvironmentType; id: string },
taskIdentifier: string
) {
const task = await findTask(environment, taskIdentifier);
if (!task?.queueId) {
return undefined;
}
return $replica.taskQueue.findFirst({
where: {
runtimeEnvironmentId: environment.id,
id: task.queueId,
},
select: {
friendlyId: true,
name: true,
type: true,
paused: true,
},
});
}
function listLatestBackgroundWorkers(environment: { id: string }, limit = 20) {
return $replica.backgroundWorker.findMany({
where: {
runtimeEnvironmentId: environment.id,
},
select: {
version: true,
engine: true,
},
orderBy: {
createdAt: "desc",
},
take: limit,
});
}