Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";

const ParamsSchema = z.object({
key: z.string(),
});

const BodySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});

export const { action } = createActionApiRoute(
{
params: ParamsSchema,
body: BodySchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "write",
resource: () => ({}),
superScopes: ["write:runs", "admin"],
},
},
async ({ params, body, authentication }) => {
const service = new ResetIdempotencyKeyService();

try {
const result = await service.call(params.key, body.taskIdentifier, authentication.environment);
return json(result, { status: 200 });
} catch (error) {
if (error instanceof Error) {
return json({ error: error.message }, { status: 404 });
}
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
);
Comment on lines +1 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tighten error handling: don’t turn all errors into 404s or leak messages

The happy path is good, but the current try/catch:

} catch (error) {
  if (error instanceof Error) {
    return json({ error: error.message }, { status: 404 });
  }
  return json({ error: "Internal Server Error" }, { status: 500 });
}

has a few issues:

  • Any runtime error (including unexpected server/DB failures) is surfaced as a 404, which is misleading.
  • Raw error.message is returned to clients, potentially leaking internal details.
  • It ignores the status already carried by ServiceValidationError.

A safer pattern is to only treat ServiceValidationError as a 4xx and default everything else to 500 with a generic message. For example:

-import { json } from "@remix-run/server-runtime";
+import { json } from "@remix-run/server-runtime";
+import { ServiceValidationError } from "~/v3/services/baseService.server";
@@
-  async ({ params, body, authentication }) => {
+  async ({ params, body, authentication }) => {
@@
-    try {
-      const result = await service.call(params.key, body.taskIdentifier, authentication.environment);
-      return json(result, { status: 200 });
-    } catch (error) {
-      if (error instanceof Error) {
-        return json({ error: error.message }, { status: 404 });
-      }
-      return json({ error: "Internal Server Error" }, { status: 500 });
-    }
+    try {
+      const result = await service.call(
+        params.key,
+        body.taskIdentifier,
+        authentication.environment
+      );
+      return json(result, { status: 200 });
+    } catch (error) {
+      if (error instanceof ServiceValidationError) {
+        return json({ error: error.message }, { status: error.status ?? 400 });
+      }
+
+      // Optionally log `error` here
+      return json({ error: "Internal Server Error" }, { status: 500 });
+    }
   }
 );

(or, if createActionApiRoute already handles ServiceValidationError globally, you can simply drop the try/catch and let it bubble).

This keeps client semantics accurate and avoids over‑exposing internal error messages.

🤖 Prompt for AI Agents
In apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts lines 1-39, the
catch block currently maps all Errors to a 404 and returns raw error.message;
update error handling to only treat ServiceValidationError as a client error
(use its status and safe message), and map every other error to a 500 with a
generic "Internal Server Error" payload (do not return raw error.message);
alternatively, if createActionApiRoute already handles ServiceValidationError
globally, remove the try/catch entirely and let errors bubble to the global
handler.

Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { parse } from "@conform-to/zod";
import { type ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { environment } from "effect/Differ";
Comment on lines +10 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove unused imports.

authenticateApiRequest and environment from "effect/Differ" are imported but never used. Additionally, the environment import from "effect/Differ" shadows the local variable environment declared at line 76, which could cause confusion during future maintenance.

 import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
 import { v3RunParamsSchema } from "~/utils/pathBuilder";
-import { authenticateApiRequest } from "~/services/apiAuth.server";
-import { environment } from "effect/Differ";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { environment } from "effect/Differ";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
🤖 Prompt for AI Agents
In
apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset.tsx
around lines 10 to 11, remove the unused imports: delete the import of
authenticateApiRequest and the import of environment from "effect/Differ" (the
latter also shadows the local environment variable declared later). Update the
import list to only include actually used symbols and run the linter or
TypeScript check to verify no remaining references to those names.


export const resetIdempotencyKeySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});

export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, runParam } =
v3RunParamsSchema.parse(params);

const formData = await request.formData();
const submission = parse(formData, { schema: resetIdempotencyKeySchema });

if (!submission.value) {
return json(submission);
}

try {
const { taskIdentifier } = submission.value;

const taskRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
project: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
runtimeEnvironment: {
slug: envParam,
},
},
select: {
id: true,
idempotencyKey: true,
taskIdentifier: true,
runtimeEnvironmentId: true,
},
});

if (!taskRun) {
submission.error = { runParam: ["Run not found"] };
return json(submission);
}

if (!taskRun.idempotencyKey) {
return jsonWithErrorMessage(
submission,
request,
"This run does not have an idempotency key"
);
}

if (taskRun.taskIdentifier !== taskIdentifier) {
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
return json(submission);
}

const environment = await prisma.runtimeEnvironment.findUnique({
where: {
id: taskRun.runtimeEnvironmentId,
},
include: {
project: {
include: {
organization: true,
},
},
},
});

if (!environment) {
return jsonWithErrorMessage(
submission,
request,
"Environment not found"
);
}

const service = new ResetIdempotencyKeyService();

await service.call(taskRun.idempotencyKey, taskIdentifier, {
...environment,
organizationId: environment.project.organizationId,
organization: environment.project.organization,
});

return jsonWithSuccessMessage(
{ success: true },
request,
"Idempotency key reset successfully"
);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to reset idempotency key", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${error.message}`
);
} else {
logger.error("Failed to reset idempotency key", { error });
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${JSON.stringify(error)}`
);
}
}
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ArrowPathIcon,
CheckIcon,
CloudArrowDownIcon,
EnvelopeIcon,
Expand Down Expand Up @@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { toast } from "sonner";
import {
Table,
TableBody,
Expand All @@ -40,6 +42,7 @@ import {
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { ToastUI } from "~/components/primitives/Toast";
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import { RunIcon } from "~/components/runs/v3/RunIcon";
Expand Down Expand Up @@ -69,6 +72,7 @@ import {
v3BatchPath,
v3DeploymentVersionPath,
v3RunDownloadLogsPath,
v3RunIdempotencyKeyResetPath,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
Expand All @@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
import { requireUserId } from "~/services/session.server";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
Expand Down Expand Up @@ -293,6 +298,28 @@ function RunBody({
const isAdmin = useHasAdminAccess();
const { value, replace } = useSearchParams();
const tab = value("tab");
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();

// Handle toast messages from the reset action
useEffect(() => {
if (resetFetcher.data && resetFetcher.state === "idle") {
// Check if the response indicates success
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
toast.custom(
(t) => (
<ToastUI
variant="success"
message="Idempotency key reset successfully"
t={t as string}
/>
),
{
duration: 5000,
}
);
}
}
}, [resetFetcher.data, resetFetcher.state]);

return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
Expand Down Expand Up @@ -543,17 +570,37 @@ function RunBody({
<Property.Item>
<Property.Label>Idempotency</Property.Label>
<Property.Value>
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
"–"
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
"–"
)}
</div>
)}
</div>
)}
{run.idempotencyKey && (
<resetFetcher.Form
method="post"
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
>
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
<Button
type="submit"
variant="minimal/small"
LeadingIcon={ArrowPathIcon}
disabled={resetFetcher.state === "submitting"}
>
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
</Button>
</resetFetcher.Form>
)}
</div>
</Property.Value>
</Property.Item>
<Property.Item>
Expand Down
11 changes: 11 additions & 0 deletions apps/webapp/app/utils/pathBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@ export function v3RunStreamingPath(
return `${v3RunPath(organization, project, environment, run)}/stream`;
}

export function v3RunIdempotencyKeyResetPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
) {
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
project
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
}

export function v3SchedulesPath(
organization: OrgForPath,
project: ProjectForPath,
Expand Down
44 changes: 44 additions & 0 deletions apps/webapp/app/v3/services/resetIdempotencyKey.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService, ServiceValidationError } from "./baseService.server";

export class ResetIdempotencyKeyService extends BaseService {
public async call(
idempotencyKey: string,
taskIdentifier: string,
authenticatedEnv: AuthenticatedEnvironment
): Promise<{ id: string }> {
// Find all runs with this idempotency key and task identifier in the authenticated environment
const runs = await this._prisma.taskRun.findMany({
where: {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
},
select: {
id: true,
},
});

if (runs.length === 0) {
throw new ServiceValidationError(
`No runs found with idempotency key: ${idempotencyKey} and task: ${taskIdentifier}`,
404
);
}

// Update all runs to clear the idempotency key
await this._prisma.taskRun.updateMany({
where: {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
},
data: {
idempotencyKey: null,
idempotencyKeyExpiresAt: null,
},
});

return { id: idempotencyKey };
}
}
23 changes: 23 additions & 0 deletions docs/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ function hash(payload: any): string {
}
```

## Resetting idempotency keys

You can reset an idempotency key to clear it from all associated runs. This is useful if you need to allow a task to be triggered again with the same idempotency key.

When you reset an idempotency key, it will be cleared for all runs that match both the task identifier and the idempotency key in the current environment. This allows you to trigger the task again with the same key.

```ts
import { idempotencyKeys } from "@trigger.dev/sdk";

// Reset an idempotency key for a specific task
await idempotencyKeys.reset("my-task", "my-idempotency-key");
```

The `reset` function requires both parameters:
- `taskIdentifier`: The identifier of the task (e.g., `"my-task"`)
- `idempotencyKey`: The idempotency key to reset

After resetting, any subsequent triggers with the same idempotency key will create new task runs instead of returning the existing ones.

<Note>
Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination.
</Note>
Comment on lines +156 to +177
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify which idempotencyKey value to reset and mention not‑found behavior

The new section is clear on scope (task identifier + environment), but the example:

await idempotencyKeys.reset("my-task", "my-idempotency-key");

can be read as passing the original key material rather than the stored key value (typically the value returned from idempotencyKeys.create). The service actually matches on the exact idempotencyKey string stored on the runs, and returns a 404 when no matching runs exist.

I’d recommend tweaking the example to show reusing the same value you originally passed to trigger (e.g. a variable obtained from idempotencyKeys.create) and optionally noting that resetting a non‑existent key/task combination results in a 404, so SDK users know to handle that case.

🤖 Prompt for AI Agents
In docs/idempotency.mdx around lines 156 to 177, clarify that the reset call
must pass the exact stored idempotency key string (typically the value returned
from idempotencyKeys.create or the same variable you passed to trigger) rather
than inferred/original key material, and update the example to show using that
variable; also add a short note that attempting to reset a non-existent task+key
combination returns a 404 so callers should handle that error case.


## Important notes

Idempotency keys, even the ones scoped globally, are actually scoped to the task and the environment. This means that you cannot collide with keys from other environments (e.g. dev will never collide with prod), or to other projects and orgs.
Expand Down
Loading