Files
2026-07-13 13:32:57 +08:00

157 lines
6.0 KiB
TypeScript

import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import type { TaskRun } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
import { sanitizeTriggerSource } from "~/utils/triggerSource";
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
const ParamsSchema = z.object({
/* This is the run friendly ID */
runParam: z.string(),
});
// Subset of TaskRun fields that ReplayTaskRunService.call actually
// reads from `existingTaskRun`. Validate the buffered fallback against
// this before casting to TaskRun so a buffer-format drift surfaces as a
// 404/422 here rather than as a silent NaN/undefined deep inside
// replay. The full TaskRun type has many more fields the service never
// touches; we only assert the ones it reads.
const BufferedReplayInputSchema = z.object({
id: z.string(),
friendlyId: z.string(),
runtimeEnvironmentId: z.string(),
taskIdentifier: z.string(),
payload: z.string(),
payloadType: z.string(),
queue: z.string(),
isTest: z.boolean(),
traceId: z.string(),
spanId: z.string(),
engine: z.string(),
runTags: z.array(z.string()),
// Nullable / optional fields the service tolerates via `??` fallbacks.
concurrencyKey: z.string().nullable().optional(),
workerQueue: z.string().nullable().optional(),
machinePreset: z.string().nullable().optional(),
realtimeStreamsVersion: z.string().nullable().optional(),
// ReplayTaskRunService.getExistingMetadata reads these to preserve
// the original run's metadata on replay. Without them in the schema
// they'd be stripped by Zod's default key-passthrough behaviour, and
// a buffered-source replay would silently lose metadata that a
// PG-source replay carries over.
seedMetadata: z.string().nullable().optional(),
seedMetadataType: z.string().nullable().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return json({ error: "Invalid or missing run ID" }, { status: 400 });
}
const { runParam } = parsed.data;
try {
const env = authenticationResult.environment;
// PG-first. Replay works on any status per audit — no
// filter beyond friendlyId is the existing semantic; findFirst with
// env scoping tightens it minimally without changing behaviour for
// a correctly-authed caller.
let taskRun: TaskRun | null = await runStore.findRun(
{
friendlyId: runParam,
runtimeEnvironmentId: env.id,
},
prisma
);
if (!taskRun) {
// Buffered fallback. SyntheticRun carries every field
// ReplayTaskRunService reads from a TaskRun. Validate the subset of
// fields the service consumes (BufferedReplayInputSchema above)
// before casting; a schema mismatch surfaces as a 404 here rather
// than as a silent undefined deep inside the service.
const buffered = await findRunByIdWithMollifierFallback({
runId: runParam,
environmentId: env.id,
organizationId: env.organizationId,
});
if (buffered) {
const parsed = BufferedReplayInputSchema.safeParse(buffered);
if (parsed.success) {
// Manual sync point: `BufferedReplayInputSchema` covers only
// the subset of `TaskRun` fields `ReplayTaskRunService.call`
// currently reads from `existingTaskRun`. The cast is `as
// unknown as TaskRun` because the full `TaskRun` type carries
// ~40 fields the service never touches; mirroring all of them
// on a synthetic snapshot would be misleading. If a future
// change to `ReplayTaskRunService` reads an additional
// `existingTaskRun` field, **add it to the schema above** —
// otherwise the buffered path will silently feed the service
// `undefined` for that field while the PG-source replay
// works. The `safeParse` + warn-log + 404 below is the
// run-time fail-safe; this comment is the design fail-safe.
taskRun = parsed.data as unknown as TaskRun;
} else {
logger.warn("replay: buffered fallback failed schema validation", {
runParam,
issues: parsed.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
});
}
}
}
if (!taskRun) {
return json({ error: "Run not found" }, { status: 404 });
}
const triggerSource = sanitizeTriggerSource(request.headers.get("x-trigger-source")) ?? "api";
const service = new ReplayTaskRunService();
const newRun = await service.call(taskRun, { triggerSource });
if (!newRun) {
return json({ error: "Failed to create new run" }, { status: 400 });
}
return json({
id: newRun?.friendlyId,
});
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to replay run", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
run: runParam,
});
return json({ error: clientSafeErrorMessage(error) }, { status: 400 });
} else {
logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam });
return json({ error: JSON.stringify(error) }, { status: 400 });
}
}
}