Skip to content

Commit ff2aa23

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,sdk,core,cli): replay a run with overrides from the API, SDK and MCP
Replay a run with a different payload, metadata or trigger options from the API, the SDK and the MCP server. The replay endpoint takes an optional body using the same option names as `trigger()`; an empty body replays the run unchanged, so existing clients keep working. ```ts import { runs } from "@trigger.dev/sdk"; const { id } = await runs.replay("run_1234", { payload: { userId: "user_456" }, metadata: { source: "replay" }, tags: ["replayed"], machine: "large-1x", version: "20250101.1", delay: "10m", }); ``` Supported overrides: `payload`, `metadata`, `tags`, `queue`, `concurrencyKey`, `machine`, `version`, `region`, `delay`, `ttl`, `idempotencyKey`, `idempotencyKeyTTL`, `maxAttempts`, `maxDuration` and `priority`. The SDK serializes a payload override like `trigger()` does, so `Date`, `BigInt`, `Map` and `Set` values keep their types; raw HTTP callers send plain JSON, or a SuperJSON string with `payloadType: "application/super+json"`. The replay stays in the original run's environment. - The MCP server has a new `replay_run` tool with the same overrides. It is hidden in `--readonly` mode. - The replay endpoint accepts public access tokens. Every replay needs permission to trigger the run's task as well as write access to the run, the same as the trigger API. - Replay reports why it failed: an unknown queue, an undeployed version or an invalid option returns its message instead of "Failed to create new run", and an unknown field in the body is rejected. - A payload override now applies to runs whose payload was offloaded to object storage. - Replaying from the dashboard keeps the original payload for runs whose payload can't be edited there, instead of sending an empty object. - Triggering with an unknown `lockToVersion` returns a 4xx with a message instead of a 500. Mono-RevId: 889adbbf384910d821b41d74c75e02675c145a00
1 parent 34ae940 commit ff2aa23

20 files changed

Lines changed: 622 additions & 33 deletions

File tree

‎.changeset/mcp-replay-run.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Add a `replay_run` tool to the MCP server, so your AI assistant can replay a run, optionally with a new payload, metadata, tags, machine, version, delay or other trigger options. It is hidden in `--readonly` mode.

‎.changeset/replay-run-overrides.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
`runs.replay()` now takes options, so you can replay a run with a different payload, metadata, tags, queue, machine, version, delay and other trigger options. Anything you leave out comes from the original run, as before, and the payload is serialized like `trigger()`, so values like `Date` and `BigInt` keep their types.
7+
8+
```ts
9+
import { runs } from "@trigger.dev/sdk";
10+
11+
await runs.replay("run_1234", {
12+
payload: { userId: "user_456" },
13+
tags: ["replayed"],
14+
delay: "10m",
15+
});
16+
```

‎apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1+
import { EngineServiceValidationError } from "@internal/run-engine";
12
import { json } from "@remix-run/server-runtime";
3+
import { ReplayRunRequestBody } from "@trigger.dev/core/v3";
24
import type { TaskRun } from "@trigger.dev/database";
35
import { z } from "zod";
6+
import { fromZodError } from "zod-validation-error";
47
import { prisma } from "~/db.server";
8+
import { env } from "~/env.server";
59
import { runStore } from "~/v3/runStore.server";
610
import { logger } from "~/services/logger.server";
711
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
812
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
13+
import { ServiceValidationError } from "~/v3/services/baseService.server";
914
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
1015
import { resolveRunForMutation } from "~/v3/mollifier/resolveRunForMutation.server";
1116
import { sanitizeTriggerSource } from "~/utils/triggerSource";
1217
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
18+
import { readBoundedBodyText } from "~/utils/boundedRequestBody.server";
19+
import { safeJsonParse } from "~/utils/json";
1320

1421
const ParamsSchema = z.object({
1522
/* This is the run friendly ID */
@@ -49,10 +56,54 @@ const BufferedReplayInputSchema = z.object({
4956
seedMetadataType: z.string().nullable().optional(),
5057
});
5158

59+
async function parseReplayOverrides(
60+
request: Request
61+
): Promise<
62+
{ ok: true; overrides: z.infer<typeof ReplayRunRequestBody> } | { ok: false; response: Response }
63+
> {
64+
const body = await readBoundedBodyText(request, env.TASK_PAYLOAD_MAXIMUM_SIZE);
65+
66+
if (!body.ok) {
67+
return {
68+
ok: false,
69+
response: json(
70+
{
71+
error: `Request body exceeds the maximum size of ${env.TASK_PAYLOAD_MAXIMUM_SIZE} bytes`,
72+
},
73+
{ status: 413 }
74+
),
75+
};
76+
}
77+
78+
const rawBody = body.text;
79+
80+
if (rawBody.trim().length === 0) {
81+
return { ok: true, overrides: {} };
82+
}
83+
84+
const parsedJson = safeJsonParse(rawBody);
85+
86+
if (parsedJson === null || typeof parsedJson !== "object") {
87+
return { ok: false, response: json({ error: "Invalid JSON" }, { status: 400 }) };
88+
}
89+
90+
const parsed = ReplayRunRequestBody.safeParse(parsedJson);
91+
92+
if (!parsed.success) {
93+
return {
94+
ok: false,
95+
response: json({ error: fromZodError(parsed.error).toString() }, { status: 400 }),
96+
};
97+
}
98+
99+
return { ok: true, overrides: parsed.data };
100+
}
101+
52102
const { action } = createActionApiRoute(
53103
{
54104
params: ParamsSchema,
55105
method: "POST",
106+
allowJWT: true,
56107
findResource: (params, authentication) =>
57108
resolveRunForMutation({
58109
runParam: params.runParam,
@@ -70,9 +121,17 @@ const { action } = createActionApiRoute(
70121
: { type: "runs", id: params.runParam },
71122
},
72123
},
73-
async ({ request, params, authentication }) => {
124+
async ({ request, params, authentication, ability }) => {
74125
const { runParam } = params;
75126

127+
const body = await parseReplayOverrides(request);
128+
129+
if (!body.ok) {
130+
return body.response;
131+
}
132+
133+
const { overrides } = body;
134+
76135
try {
77136
const env = authentication.environment;
78137
// PG-first. Replay works on any status per audit — no
@@ -130,10 +189,42 @@ const { action } = createActionApiRoute(
130189
return json({ error: "Run not found" }, { status: 404 });
131190
}
132191

192+
if (!ability.can("trigger", { type: "tasks", id: taskRun.taskIdentifier })) {
193+
return json(
194+
{
195+
error: `Replaying this run needs permission to trigger the ${taskRun.taskIdentifier} task`,
196+
},
197+
{ status: 403 }
198+
);
199+
}
200+
133201
const triggerSource = sanitizeTriggerSource(request.headers.get("x-trigger-source")) ?? "api";
134202

135203
const service = new ReplayTaskRunService();
136-
const newRun = await service.call(taskRun, { triggerSource });
204+
const newRun = await service.call(taskRun, {
205+
triggerSource,
206+
payload:
207+
overrides.payload === undefined
208+
? undefined
209+
: overrides.payloadType === "application/super+json"
210+
? (overrides.payload as string)
211+
: JSON.stringify(overrides.payload),
212+
payloadType: overrides.payloadType ?? "application/json",
213+
metadata: overrides.metadata,
214+
tags: typeof overrides.tags === "string" ? [overrides.tags] : overrides.tags,
215+
queue: overrides.queue,
216+
concurrencyKey: overrides.concurrencyKey,
217+
machine: overrides.machine,
218+
version: overrides.version,
219+
region: overrides.region,
220+
delay: overrides.delay,
221+
ttl: overrides.ttl,
222+
idempotencyKey: overrides.idempotencyKey,
223+
idempotencyKeyTTL: overrides.idempotencyKeyTTL,
224+
maxAttempts: overrides.maxAttempts,
225+
maxDurationSeconds: overrides.maxDuration,
226+
prioritySeconds: overrides.priority,
227+
});
137228

138229
if (!newRun) {
139230
return json({ error: "Failed to create new run" }, { status: 400 });
@@ -143,6 +234,13 @@ const { action } = createActionApiRoute(
143234
id: newRun?.friendlyId,
144235
});
145236
} catch (error) {
237+
if (
238+
error instanceof ServiceValidationError ||
239+
error instanceof EngineServiceValidationError
240+
) {
241+
return json({ error: error.message }, { status: error.status ?? 400 });
242+
}
243+
146244
if (error instanceof Error) {
147245
logger.error("Failed to replay run", {
148246
error: {

‎apps/webapp/app/v3/engineVersion.server.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
findCurrentWorkerFromEnvironment,
55
getCurrentWorkerDeploymentEngineVersion,
66
} from "./models/workerDeployment.server";
7+
import { ServiceValidationError } from "./services/common.server";
78

89
// Co-locate the per-env run-ops residency/mint decision next to the
910
// engine-version decision. determineEngineVersion is intentionally left untouched so its
@@ -57,7 +58,9 @@ export async function determineEngineVersion({
5758
});
5859

5960
if (!worker) {
60-
throw new Error(`Worker not found: environment: ${environment.id} version: ${workerVersion}`);
61+
throw new ServiceValidationError(
62+
`Version ${workerVersion} was not found in this environment`
63+
);
6164
}
6265

6366
return worker.engine;

‎apps/webapp/app/v3/replayTask.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const ReplayRunData = z
99
.optional()
1010
.transform((val, ctx) => {
1111
if (!val) {
12-
return "{}";
12+
return undefined;
1313
}
1414

1515
try {

‎apps/webapp/app/v3/services/replayTaskRun.server.ts‎

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import {
44
parsePacket,
55
stringifyIO,
66
} from "@trigger.dev/core/v3";
7+
import { EngineServiceValidationError } from "@internal/run-engine";
78
import { type TaskRun } from "@trigger.dev/database";
89
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
910
import { baseWorkerQueue } from "~/runEngine/concerns/workerQueueSplit.server";
1011
import { logger } from "~/services/logger.server";
11-
import { BaseService } from "./baseService.server";
12+
import { BaseService, ServiceValidationError } from "./baseService.server";
1213
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
1314
import { type RunOptionsData } from "../testTask";
1415
import { replaceSuperJsonPayload } from "@trigger.dev/core/v3/utils/ioSerialization";
@@ -17,9 +18,18 @@ import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGl
1718
type OverrideOptions = {
1819
environmentId?: string;
1920
payload?: string;
21+
/**
22+
* The encoding of `payload`, used as-is for the new run. Without it, an override of a
23+
* SuperJSON payload is plain JSON that keeps the original payload's type annotations,
24+
* which is what the dashboard's payload editor relies on.
25+
*/
26+
payloadType?: "application/json" | "application/super+json";
2027
metadata?: unknown;
2128
bulkActionId?: string;
2229
triggerSource?: string;
30+
delay?: string | Date;
31+
ttl?: string | number;
32+
idempotencyKeyTTL?: string;
2333
// zod v4 types `.optional().transform()` fields as required-with-undefined in the
2434
// output; Partial keeps them omittable for callers passing a subset of overrides.
2535
} & Partial<RunOptionsData>;
@@ -90,7 +100,8 @@ export class ReplayTaskRunService extends BaseService {
90100

91101
const payloadPacket = await this.overrideExistingPayloadPacket(
92102
existingTaskRun,
93-
overrideOptions.payload
103+
overrideOptions.payload,
104+
overrideOptions.payloadType
94105
);
95106
const parsedPayload =
96107
payloadPacket.dataType === "application/json"
@@ -118,6 +129,12 @@ export class ReplayTaskRunService extends BaseService {
118129
},
119130
});
120131

132+
if (overrideOptions.queue && !taskQueue) {
133+
throw new ServiceValidationError(
134+
`Queue "${overrideOptions.queue}" was not found in this environment`
135+
);
136+
}
137+
121138
const triggerTaskService = new TriggerTaskService();
122139
const result = await triggerTaskService.call(
123140
existingTaskRun.taskIdentifier,
@@ -134,14 +151,18 @@ export class ReplayTaskRunService extends BaseService {
134151
test: existingTaskRun.isTest,
135152
tags,
136153
metadata: metadata,
137-
delay: overrideOptions.delaySeconds
138-
? new Date(Date.now() + overrideOptions.delaySeconds * 1000)
139-
: undefined,
140-
ttl: overrideOptions.ttlSeconds,
154+
delay:
155+
overrideOptions.delay ??
156+
(overrideOptions.delaySeconds
157+
? new Date(Date.now() + overrideOptions.delaySeconds * 1000)
158+
: undefined),
159+
ttl: overrideOptions.ttl ?? overrideOptions.ttlSeconds,
141160
idempotencyKey: overrideOptions.idempotencyKey,
142-
idempotencyKeyTTL: overrideOptions.idempotencyKeyTTLSeconds
143-
? `${overrideOptions.idempotencyKeyTTLSeconds}s`
144-
: undefined,
161+
idempotencyKeyTTL:
162+
overrideOptions.idempotencyKeyTTL ??
163+
(overrideOptions.idempotencyKeyTTLSeconds
164+
? `${overrideOptions.idempotencyKeyTTLSeconds}s`
165+
: undefined),
145166
concurrencyKey:
146167
overrideOptions.concurrencyKey ?? existingTaskRun.concurrencyKey ?? undefined,
147168
/**
@@ -188,6 +209,13 @@ export class ReplayTaskRunService extends BaseService {
188209
return;
189210
}
190211

212+
if (
213+
error instanceof ServiceValidationError ||
214+
error instanceof EngineServiceValidationError
215+
) {
216+
throw error;
217+
}
218+
191219
logger.error("Failed to replay a run", {
192220
error: error instanceof Error ? error.message : error,
193221
});
@@ -198,16 +226,21 @@ export class ReplayTaskRunService extends BaseService {
198226

199227
private async overrideExistingPayloadPacket(
200228
existingTaskRun: TaskRun,
201-
stringifiedPayloadOverride: string | undefined
229+
stringifiedPayloadOverride: string | undefined,
230+
payloadType: "application/json" | "application/super+json" | undefined
202231
) {
203-
if (existingTaskRun.payloadType === "application/store") {
232+
if (stringifiedPayloadOverride === undefined) {
204233
return conditionallyImportPacket({
205234
data: existingTaskRun.payload,
206235
dataType: existingTaskRun.payloadType,
207236
});
208237
}
209238

210-
if (stringifiedPayloadOverride && existingTaskRun.payloadType === "application/super+json") {
239+
if (payloadType) {
240+
return { data: stringifiedPayloadOverride, dataType: payloadType };
241+
}
242+
243+
if (existingTaskRun.payloadType === "application/super+json") {
211244
const newPayload = await replaceSuperJsonPayload(
212245
existingTaskRun.payload,
213246
stringifiedPayloadOverride
@@ -216,10 +249,7 @@ export class ReplayTaskRunService extends BaseService {
216249
return stringifyIO(newPayload);
217250
}
218251

219-
return conditionallyImportPacket({
220-
data: stringifiedPayloadOverride ?? existingTaskRun.payload,
221-
dataType: existingTaskRun.payloadType,
222-
});
252+
return { data: stringifiedPayloadOverride, dataType: "application/json" };
223253
}
224254

225255
private async getExistingMetadata(existingTaskRun: TaskRun) {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import { ReplayRunData } from "~/v3/replayTask";
3+
4+
describe("ReplayRunData", () => {
5+
it("leaves the payload unset when the form does not send one", () => {
6+
const result = ReplayRunData.safeParse({ failedRedirect: "/runs" });
7+
8+
expect(result.success).toBe(true);
9+
expect(result.data?.payload).toBeUndefined();
10+
});
11+
12+
it("keeps an edited payload", () => {
13+
const result = ReplayRunData.safeParse({ failedRedirect: "/runs", payload: '{"n":2}' });
14+
15+
expect(result.success).toBe(true);
16+
expect(result.data?.payload).toBe('{"n":2}');
17+
});
18+
19+
it("rejects a payload that is not JSON", () => {
20+
const result = ReplayRunData.safeParse({ failedRedirect: "/runs", payload: "{nope" });
21+
22+
expect(result.success).toBe(false);
23+
});
24+
});

‎docs/mcp-tools.mdx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ Cancel a running or queued run.
102102
- `"Cancel run run_abc123"`
103103
- `"Stop that task"`
104104

105+
### replay_run
106+
107+
Replay a run as a new run of the same task, in the same environment. The new run reuses the original run's payload, metadata, tags, queue, concurrency key, and machine unless you override them. See [Replaying](/replaying) for how each option falls back.
108+
109+
**Parameters:**
110+
- `runId` (required): The run to replay, starting with `run_`
111+
- `payload`, `metadata`, `tags` (optional): Replace the original run's values
112+
- `queue`, `concurrencyKey`, `machine`, `version`, `region`, `delay`, `ttl`, `idempotencyKey`, `maxAttempts`, `maxDuration`, `priority` (optional): Override the trigger options for the new run
113+
114+
**Example usage:**
115+
- `"Replay run run_abc123"`
116+
- `"Replay that failed run with userId set to user_456"`
117+
- `"Replay run run_abc123 on a large-1x machine in 10 minutes"`
118+
105119
## Deployment Tools
106120

107121
### deploy
@@ -225,7 +239,7 @@ Check the status of the dev server and view recent output. Shows whether it is s
225239
- `"Are there any build errors?"`
226240

227241
<Callout type="warning">
228-
The deploy and list_preview_branches tools are not available when the MCP server is running with the `--dev-only` flag. The `--readonly` flag hides deploy, trigger_task, and cancel_run.
242+
The deploy and list_preview_branches tools are not available when the MCP server is running with the `--dev-only` flag. The `--readonly` flag hides deploy, trigger_task, cancel_run, and replay_run.
229243
</Callout>
230244

231245
## Agent Chat Tools

0 commit comments

Comments
 (0)