import type { MollifierBuffer } from "@trigger.dev/redis-worker"; import { RunId } from "@trigger.dev/core/v3/isomorphic"; import { IdempotencyKeyOptionsSchema } from "@trigger.dev/core/v3/schemas"; import type { z } from "zod"; import { logger } from "~/services/logger.server"; import { deserialiseMollifierSnapshot } from "./mollifierSnapshot.server"; import { getMollifierBuffer } from "./mollifierBuffer.server"; export type ReadFallbackInput = { runId: string; environmentId: string; organizationId: string; }; export type SyntheticRun = { // Snapshot-derived TaskRun primary key. Used by ReplayTaskRunService // for logging and by callers passing this object where a TaskRun is // expected (cast). Derived deterministically from `friendlyId`. id: string; friendlyId: string; status: "QUEUED" | "FAILED" | "CANCELED"; // Set when the customer cancelled the run via the dashboard or API // while it was buffered. The drainer's cancel bifurcation reads this // on next pop and writes a CANCELED PG row directly (skipping // materialisation). Reflected back into the UI by the synthesised // SpanRun so the run-detail page shows the cancelled state even before // the drainer materialises it. cancelledAt: Date | undefined; cancelReason: string | undefined; // Reschedule patch (`set_delay`) writes `delayUntil` into the snapshot. // Surfacing it on SyntheticRun lets the retrieve-run shape reflect the // pending delay before the drainer materialises the PG row. delayUntil: Date | undefined; taskIdentifier: string | undefined; createdAt: Date; payload: unknown; payloadType: string | undefined; metadata: unknown; metadataType: string | undefined; // Seed-metadata mirrors what `triggerTask.server.ts` writes into the // snapshot: the original metadataPacket data preserved separately from // any later customer mutations. ReplayTaskRunService uses these to // rebuild the replay's metadata. seedMetadata: string | undefined; seedMetadataType: string | undefined; idempotencyKey: string | undefined; // Surfaced for the cached-hit expiration check in IdempotencyKeyConcern. // The PG-resident path enforces this (clears key, allows new run when // expired). For buffered runs the snapshot carries the same field — we // expose it here so the cached-hit branch can apply the same check // rather than indefinitely returning the buffered run's id. idempotencyKeyExpiresAt: Date | undefined; // `{ key, scope }` object form, matching how the SDK serialises and PG // stores it. Previously typed as `string[]` (legacy/incorrect — Prisma // is `Json?` carrying the schema-shaped object). `getUserProvidedIdempotencyKey` // and `extractIdempotencyKeyScope` both parse via the same Zod schema; // they returned `undefined` for the array-shape, which silently // demoted the response to surface the hash instead of the user- // provided key for buffered runs — a contract divergence from // PG-resident runs. See the regression test in `mollifierReadFallback.test.ts`. idempotencyKeyOptions: z.infer | undefined; isTest: boolean; depth: number; ttl: string | undefined; tags: string[]; // Mirror of `tags` under the PG field name. ReplayTaskRunService reads // `existingTaskRun.runTags`; both names are kept here so a synthetic // run can be passed wherever the PG-shape `runTags` is expected. runTags: string[]; lockedToVersion: string | undefined; resumeParentOnCompletion: boolean; parentTaskRunId: string | undefined; // Allocated at gate-accept time and embedded in the snapshot so the run's // trace is continuous from QUEUED-in-buffer through executing post-drain. traceId: string | undefined; spanId: string | undefined; parentSpanId: string | undefined; // Replay-relevant fields populated from the engine-trigger snapshot. // ReplayTaskRunService reads each of these from the existing TaskRun; // when the original lives in the buffer we synthesise them here. runtimeEnvironmentId: string | undefined; engine: "V2"; workerQueue: string | undefined; region: string | undefined; queue: string | undefined; concurrencyKey: string | undefined; machinePreset: string | undefined; realtimeStreamsVersion: string | undefined; // Additional snapshot-sourced fields used when synthesising a SpanRun // for the dashboard's right-side details panel. All optional because // older snapshots may not carry them. maxAttempts: number | undefined; maxDurationInSeconds: number | undefined; replayedFromTaskRunFriendlyId: string | undefined; annotations: unknown; traceContext: unknown; scheduleId: string | undefined; batchId: string | undefined; parentTaskRunFriendlyId: string | undefined; rootTaskRunFriendlyId: string | undefined; error?: { code: string; message: string }; }; export type ReadFallbackDeps = { getBuffer?: () => MollifierBuffer | null; }; function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function asStringArray(value: unknown): string[] { return Array.isArray(value) && value.every((v) => typeof v === "string") ? (value as string[]) : []; } function asDate(value: unknown): Date | undefined { const raw = asString(value); if (!raw) return undefined; const parsed = new Date(raw); return Number.isNaN(parsed.getTime()) ? undefined : parsed; } // Snapshot ids are written by engine.trigger as INTERNAL ids (cuids); the // SyntheticRun contract exposes friendlyIds. `RunId.toFriendlyId` is // already used for the synthetic run's own id (line 155); reuse it for // parent/root so consumers see the same shape as the PG path. function internalRunIdToFriendlyId(internalId: string | undefined): string | undefined { if (!internalId) return undefined; return RunId.toFriendlyId(internalId); } export async function findRunByIdWithMollifierFallback( input: ReadFallbackInput, deps: ReadFallbackDeps = {} ): Promise { const buffer = (deps.getBuffer ?? getMollifierBuffer)(); if (!buffer) return null; try { const entry = await buffer.getEntry(input.runId); if (!entry) return null; if (entry.envId !== input.environmentId || entry.orgId !== input.organizationId) { logger.warn("mollifier read-fallback auth mismatch", { runId: input.runId, callerEnvId: input.environmentId, callerOrgId: input.organizationId, }); return null; } const snapshot = deserialiseMollifierSnapshot(entry.payload); // Parse via the canonical schema (`{ key: string, scope: "run" | // "attempt" | "global" }`) rather than the legacy Array.isArray // check. The SDK and Prisma both store this as an object; the array // form never matches, so a buffered run's response previously fell // back to the server-side hash in `getUserProvidedIdempotencyKey` // instead of the customer-supplied key — diverging from how // materialised runs render the same field. const idempotencyKeyOptionsParsed = IdempotencyKeyOptionsSchema.safeParse( snapshot.idempotencyKeyOptions ); const idempotencyKeyOptions = idempotencyKeyOptionsParsed.success ? idempotencyKeyOptionsParsed.data : undefined; const tags = asStringArray(snapshot.tags); const environment = snapshot.environment && typeof snapshot.environment === "object" ? (snapshot.environment as Record) : undefined; const cancelledAt = asDate(snapshot.cancelledAt); const cancelReason = asString(snapshot.cancelReason); let status: SyntheticRun["status"] = "QUEUED"; if (cancelledAt) { status = "CANCELED"; } else if (entry.status === "FAILED") { status = "FAILED"; } const delayUntil = asDate(snapshot.delayUntil); return { id: RunId.fromFriendlyId(entry.runId), friendlyId: entry.runId, status, cancelledAt, cancelReason, delayUntil, taskIdentifier: asString(snapshot.taskIdentifier), createdAt: entry.createdAt, payload: snapshot.payload, payloadType: asString(snapshot.payloadType), metadata: snapshot.metadata, metadataType: asString(snapshot.metadataType), seedMetadata: asString(snapshot.seedMetadata), seedMetadataType: asString(snapshot.seedMetadataType), idempotencyKey: asString(snapshot.idempotencyKey), idempotencyKeyExpiresAt: asDate(snapshot.idempotencyKeyExpiresAt), idempotencyKeyOptions, isTest: snapshot.isTest === true, depth: typeof snapshot.depth === "number" ? snapshot.depth : 0, ttl: asString(snapshot.ttl), tags, runTags: tags, lockedToVersion: asString(snapshot.taskVersion), resumeParentOnCompletion: snapshot.resumeParentOnCompletion === true, parentTaskRunId: asString(snapshot.parentTaskRunId), traceId: asString(snapshot.traceId), spanId: asString(snapshot.spanId), parentSpanId: asString(snapshot.parentSpanId), runtimeEnvironmentId: asString(environment?.id) ?? entry.envId, engine: "V2", workerQueue: asString(snapshot.workerQueue), region: asString(snapshot.region), queue: asString(snapshot.queue), concurrencyKey: asString(snapshot.concurrencyKey), machinePreset: asString(snapshot.machine), realtimeStreamsVersion: asString(snapshot.realtimeStreamsVersion), maxAttempts: typeof snapshot.maxAttempts === "number" ? snapshot.maxAttempts : undefined, maxDurationInSeconds: typeof snapshot.maxDurationInSeconds === "number" ? snapshot.maxDurationInSeconds : undefined, replayedFromTaskRunFriendlyId: asString(snapshot.replayedFromTaskRunFriendlyId), annotations: snapshot.annotations, traceContext: snapshot.traceContext, scheduleId: asString(snapshot.scheduleId), // The engine.trigger input embeds the batch as `{ id, index }` (see // triggerTask.server.ts #buildEngineTriggerInput), not as a flat // `batchId`. The nested `id` is the batch's internal cuid — the same // value PG stores in `TaskRun.batchId` — so callers reconstruct the // friendly id via `BatchId.toFriendlyId` exactly as the PG path does. batchId: asString((snapshot.batch as { id?: unknown } | undefined)?.id), // The snapshot only carries the INTERNAL parent/root ids // (`parentTaskRunId` / `rootTaskRunId` — what engine.trigger consumes), // not the friendlyIds the SyntheticRun contract expects. Convert // internal → friendly here so consumers don't have to special-case // the buffered path. parentTaskRunFriendlyId: internalRunIdToFriendlyId(asString(snapshot.parentTaskRunId)), rootTaskRunFriendlyId: internalRunIdToFriendlyId(asString(snapshot.rootTaskRunId)), error: entry.lastError, }; } catch (err) { logger.error("mollifier read-fallback errored — fail-open to null", { runId: input.runId, err: err instanceof Error ? err.message : String(err), }); return null; } }