chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user