From 8a583e752790fc2af8a5251961e925c81924b98c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 16 Jan 2026 20:00:27 +0000 Subject: [PATCH 1/8] feat(sdk): expose user-provided idempotency key and scope in task context --- .../v3/ApiRetrieveRunPresenter.server.ts | 23 ++- .../app/presenters/v3/SpanPresenter.server.ts | 24 ++- .../runEngine/services/triggerTask.server.ts | 1 + .../services/runsReplicationService.server.ts | 12 ++ ...d_task_runs_v2_idempotency_key_options.sql | 16 ++ internal-packages/clickhouse/src/taskRuns.ts | 8 + .../migration.sql | 2 + .../database/prisma/schema.prisma | 2 + .../run-engine/src/engine/index.ts | 2 + .../src/engine/systems/runAttemptSystem.ts | 27 ++- .../run-engine/src/engine/types.ts | 2 + packages/core/src/v3/idempotencyKeys.ts | 155 ++++++++++++++++-- packages/core/src/v3/schemas/api.ts | 14 ++ packages/core/src/v3/schemas/common.ts | 6 + packages/trigger-sdk/src/v3/shared.ts | 91 ++++++++-- .../hello-world/src/trigger/idempotency.ts | 87 ++++++++++ 16 files changed, 441 insertions(+), 31 deletions(-) create mode 100644 internal-packages/clickhouse/schema/013_add_task_runs_v2_idempotency_key_options.sql create mode 100644 internal-packages/database/prisma/migrations/20260116154810_add_idempotency_key_options_to_task_run/migration.sql diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index 671496586a..3e3a99cd9a 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -18,6 +18,26 @@ import { generatePresignedUrl } from "~/v3/r2.server"; import { tracer } from "~/v3/tracer.server"; import { startSpanWithEnv } from "~/v3/tracing.server"; +/** + * Returns the user-provided idempotency key if available (from idempotencyKeyOptions), + * otherwise falls back to the stored idempotency key (which is the hash). + */ +function getUserProvidedIdempotencyKey(run: { + idempotencyKey: string | null; + idempotencyKeyOptions: unknown; +}): string | undefined { + // If we have the user-provided key options, return the original key + const options = run.idempotencyKeyOptions as { + key?: string; + scope?: string; + } | null; + if (options?.key) { + return options.key; + } + // Fallback to the hash (for runs created before this feature) + return run.idempotencyKey ?? undefined; +} + // Build 'select' object const commonRunSelect = { id: true, @@ -38,6 +58,7 @@ const commonRunSelect = { baseCostInCents: true, usageDurationMs: true, idempotencyKey: true, + idempotencyKeyOptions: true, isTest: true, depth: true, scheduleId: true, @@ -442,7 +463,7 @@ async function createCommonRunStructure(run: CommonRelatedRun, apiVersion: API_V return { id: run.friendlyId, taskIdentifier: run.taskIdentifier, - idempotencyKey: run.idempotencyKey ?? undefined, + idempotencyKey: getUserProvidedIdempotencyKey(run), version: run.lockedToVersion?.version, status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion), createdAt: run.createdAt, diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index e1a60cfe6f..82ab7c6d53 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -229,7 +229,7 @@ export class SpanPresenter extends BasePresenter { isTest: run.isTest, replayedFromTaskRunFriendlyId: run.replayedFromTaskRunFriendlyId, environmentId: run.runtimeEnvironment.id, - idempotencyKey: run.idempotencyKey, + idempotencyKey: this.getUserProvidedIdempotencyKey(run), idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt, debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null, schedule: await this.resolveSchedule(run.scheduleId ?? undefined), @@ -355,6 +355,7 @@ export class SpanPresenter extends BasePresenter { //idempotency idempotencyKey: true, idempotencyKeyExpiresAt: true, + idempotencyKeyOptions: true, //debounce debounce: true, //delayed @@ -644,7 +645,7 @@ export class SpanPresenter extends BasePresenter { createdAt: run.createdAt, tags: run.runTags, isTest: run.isTest, - idempotencyKey: run.idempotencyKey ?? undefined, + idempotencyKey: this.getUserProvidedIdempotencyKey(run) ?? undefined, startedAt: run.startedAt ?? run.createdAt, durationMs: run.usageDurationMs, costInCents: run.costInCents, @@ -704,4 +705,23 @@ export class SpanPresenter extends BasePresenter { return parsedTraceparent?.traceId; } + + /** + * Returns the user-provided idempotency key if available (from idempotencyKeyOptions), + * otherwise falls back to the stored idempotency key (which is the hash). + */ + getUserProvidedIdempotencyKey( + run: Pick + ): string | null { + // If we have the user-provided key options, return the original key + const options = run.idempotencyKeyOptions as { + key?: string; + scope?: string; + } | null; + if (options?.key) { + return options.key; + } + // Fallback to the hash (for runs created before this feature) + return run.idempotencyKey; + } } diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index ab32682811..73b4febcc9 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -304,6 +304,7 @@ export class RunEngineTriggerTaskService { environment: environment, idempotencyKey, idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined, + idempotencyKeyOptions: body.options?.idempotencyKeyOptions, taskIdentifier: taskId, payload: payloadPacket.data ?? "", payloadType: payloadPacket.dataType, diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 4e4e1d0f3f..993845e988 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -891,6 +891,8 @@ export class RunsReplicationService { run.spanId, // span_id run.traceId, // trace_id run.idempotencyKey ?? "", // idempotency_key + this.#extractIdempotencyKeyUser(run), // idempotency_key_user + this.#extractIdempotencyKeyScope(run), // idempotency_key_scope run.ttl ?? "", // expiration_ttl run.isTest ?? false, // is_test _version.toString(), // _version @@ -951,6 +953,16 @@ export class RunsReplicationService { return { data: parsedData }; } + + #extractIdempotencyKeyUser(run: TaskRun): string { + const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; + return options?.key ?? ""; + } + + #extractIdempotencyKeyScope(run: TaskRun): string { + const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; + return options?.scope ?? ""; + } } export type ConcurrentFlushSchedulerConfig = { diff --git a/internal-packages/clickhouse/schema/013_add_task_runs_v2_idempotency_key_options.sql b/internal-packages/clickhouse/schema/013_add_task_runs_v2_idempotency_key_options.sql new file mode 100644 index 0000000000..345e393afa --- /dev/null +++ b/internal-packages/clickhouse/schema/013_add_task_runs_v2_idempotency_key_options.sql @@ -0,0 +1,16 @@ +-- +goose Up + +-- Add columns for storing user-provided idempotency key and scope for searching +ALTER TABLE trigger_dev.task_runs_v2 +ADD COLUMN idempotency_key_user String DEFAULT ''; + +ALTER TABLE trigger_dev.task_runs_v2 +ADD COLUMN idempotency_key_scope String DEFAULT ''; + +-- +goose Down + +ALTER TABLE trigger_dev.task_runs_v2 +DROP COLUMN idempotency_key_user; + +ALTER TABLE trigger_dev.task_runs_v2 +DROP COLUMN idempotency_key_scope; diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index 1e8df3b28d..8c1d29ac16 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -40,6 +40,8 @@ export const TaskRunV2 = z.object({ span_id: z.string(), trace_id: z.string(), idempotency_key: z.string(), + idempotency_key_user: z.string().default(""), + idempotency_key_scope: z.string().default(""), expiration_ttl: z.string(), is_test: z.boolean().default(false), concurrency_key: z.string().default(""), @@ -91,6 +93,8 @@ export const TASK_RUN_COLUMNS = [ "span_id", "trace_id", "idempotency_key", + "idempotency_key_user", + "idempotency_key_scope", "expiration_ttl", "is_test", "_version", @@ -151,6 +155,8 @@ export type TaskRunFieldTypes = { span_id: string; trace_id: string; idempotency_key: string; + idempotency_key_user: string; + idempotency_key_scope: string; expiration_ttl: string; is_test: boolean; _version: string; @@ -282,6 +288,8 @@ export type TaskRunInsertArray = [ span_id: string, trace_id: string, idempotency_key: string, + idempotency_key_user: string, + idempotency_key_scope: string, expiration_ttl: string, is_test: boolean, _version: string, diff --git a/internal-packages/database/prisma/migrations/20260116154810_add_idempotency_key_options_to_task_run/migration.sql b/internal-packages/database/prisma/migrations/20260116154810_add_idempotency_key_options_to_task_run/migration.sql new file mode 100644 index 0000000000..cd4dceb32a --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260116154810_add_idempotency_key_options_to_task_run/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "idempotencyKeyOptions" JSONB; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 2979479e2c..bbc7c98f14 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -589,6 +589,8 @@ model TaskRun { idempotencyKey String? idempotencyKeyExpiresAt DateTime? + /// Stores the user-provided key and scope: { key: string, scope: "run" | "attempt" | "global" } + idempotencyKeyOptions Json? /// Debounce options: { key: string, delay: string, createdAt: Date } debounce Json? diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 83b354fbd9..88e91b1bb3 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -395,6 +395,7 @@ export class RunEngine { environment, idempotencyKey, idempotencyKeyExpiresAt, + idempotencyKeyOptions, taskIdentifier, payload, payloadType, @@ -544,6 +545,7 @@ export class RunEngine { projectId: environment.project.id, idempotencyKey, idempotencyKeyExpiresAt, + idempotencyKeyOptions, taskIdentifier, payload, payloadType, diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 67592ccddb..fd3ec589ed 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -194,6 +194,7 @@ export class RunAttemptSystem { runTags: true, isTest: true, idempotencyKey: true, + idempotencyKeyOptions: true, startedAt: true, maxAttempts: true, taskVersion: true, @@ -261,7 +262,8 @@ export class RunAttemptSystem { isTest: run.isTest, createdAt: run.createdAt, startedAt: run.startedAt ?? run.createdAt, - idempotencyKey: run.idempotencyKey ?? undefined, + idempotencyKey: this.#getUserProvidedIdempotencyKey(run) ?? undefined, + idempotencyKeyScope: this.#getIdempotencyKeyScope(run), maxAttempts: run.maxAttempts ?? undefined, version: run.taskVersion ?? "unknown", maxDuration: run.maxDurationInSeconds ?? undefined, @@ -422,6 +424,7 @@ export class RunAttemptSystem { runTags: true, isTest: true, idempotencyKey: true, + idempotencyKeyOptions: true, startedAt: true, maxAttempts: true, taskVersion: true, @@ -570,7 +573,8 @@ export class RunAttemptSystem { createdAt: updatedRun.createdAt, tags: updatedRun.runTags, isTest: updatedRun.isTest, - idempotencyKey: updatedRun.idempotencyKey ?? undefined, + idempotencyKey: this.#getUserProvidedIdempotencyKey(updatedRun) ?? undefined, + idempotencyKeyScope: this.#getIdempotencyKeyScope(updatedRun), startedAt: updatedRun.startedAt ?? updatedRun.createdAt, maxAttempts: updatedRun.maxAttempts ?? undefined, version: updatedRun.taskVersion ?? "unknown", @@ -1914,6 +1918,25 @@ export class RunAttemptSystem { stackTrace: truncateString(error.stackTrace, 1024 * 16), // 16kb }; } + + #getUserProvidedIdempotencyKey( + run: { idempotencyKey: string | null; idempotencyKeyOptions: unknown } + ): string | null { + const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; + // Return user-provided key if available, otherwise fall back to the hash + return options?.key ?? run.idempotencyKey; + } + + #getIdempotencyKeyScope( + run: { idempotencyKeyOptions: unknown } + ): "run" | "attempt" | "global" | undefined { + const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; + const scope = options?.scope; + if (scope === "run" || scope === "attempt" || scope === "global") { + return scope; + } + return undefined; + } } export function safeParseGitMeta(git: unknown): GitMeta | undefined { diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 01990f12b1..c48316691b 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -124,6 +124,8 @@ export type TriggerParams = { environment: MinimalAuthenticatedEnvironment; idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; + /** The original user-provided idempotency key and scope */ + idempotencyKeyOptions?: { key: string; scope: "run" | "attempt" | "global" }; taskIdentifier: string; payload: string; payloadType: string; diff --git a/packages/core/src/v3/idempotencyKeys.ts b/packages/core/src/v3/idempotencyKeys.ts index 467608840b..8de39f6ace 100644 --- a/packages/core/src/v3/idempotencyKeys.ts +++ b/packages/core/src/v3/idempotencyKeys.ts @@ -4,11 +4,60 @@ import { IdempotencyKey } from "./types/idempotencyKeys.js"; import { digestSHA256 } from "./utils/crypto.js"; import type { ZodFetchOptions } from "./apiClient/core.js"; +export type IdempotencyKeyScope = "run" | "attempt" | "global"; + +export type IdempotencyKeyOptions = { + key: string; + scope: IdempotencyKeyScope; +}; + +const IDEMPOTENCY_KEY_OPTIONS_SYMBOL = Symbol.for("__idempotencyKeyOptions"); + +/** + * Extracts the user-provided key and scope from an idempotency key created with `idempotencyKeys.create()`. + * + * @param idempotencyKey The idempotency key to extract options from + * @returns The original key and scope, or undefined if the key doesn't have attached options + * + * @example + * ```typescript + * const key = await idempotencyKeys.create("my-key", { scope: "global" }); + * const options = getIdempotencyKeyOptions(key); + * // options = { key: "my-key", scope: "global" } + * ``` + */ +export function getIdempotencyKeyOptions( + idempotencyKey: IdempotencyKey | string +): IdempotencyKeyOptions | undefined { + return (idempotencyKey as any)[IDEMPOTENCY_KEY_OPTIONS_SYMBOL]; +} + +/** + * Attaches idempotency key options to a String object for later extraction. + * @internal + */ +function attachIdempotencyKeyOptions( + idempotencyKey: string, + options: IdempotencyKeyOptions +): IdempotencyKey { + const result = new String(idempotencyKey) as IdempotencyKey; + (result as any)[IDEMPOTENCY_KEY_OPTIONS_SYMBOL] = options; + return result; +} + export function isIdempotencyKey( value: string | string[] | IdempotencyKey ): value is IdempotencyKey { // Cannot check the brand at runtime because it doesn't exist (it's a TypeScript-only construct) - return typeof value === "string" && value.length === 64; + // Check for both primitive strings and String objects (created via new String()) + // String objects have typeof "object" so we also check instanceof String + if (typeof value === "string") { + return value.length === 64; + } + if (value instanceof String) { + return value.length === 64; + } + return false; } export function flattenIdempotencyKey( @@ -91,16 +140,19 @@ export async function makeIdempotencyKey( */ export async function createIdempotencyKey( key: string | string[], - options?: { scope?: "run" | "attempt" | "global" } + options?: { scope?: IdempotencyKeyScope } ): Promise { - const idempotencyKey = await generateIdempotencyKey( - [...(Array.isArray(key) ? key : [key])].concat(injectScope(options?.scope ?? "run")) - ); + const scope = options?.scope ?? "run"; + const keyArray = Array.isArray(key) ? key : [key]; + const userKey = keyArray.join("-"); + + const idempotencyKey = await generateIdempotencyKey(keyArray.concat(injectScope(scope))); - return idempotencyKey as IdempotencyKey; + // Attach the original key and scope as metadata for later extraction + return attachIdempotencyKeyOptions(idempotencyKey, { key: userKey, scope }); } -function injectScope(scope: "run" | "attempt" | "global"): string[] { +function injectScope(scope: IdempotencyKeyScope): string[] { switch (scope) { case "run": { if (taskContext?.ctx) { @@ -137,17 +189,92 @@ export function attemptKey(ctx: AttemptKeyMaterial): string { return `${ctx.run.id}-${ctx.attempt.number}`; } -/** Resets an idempotency key, effectively deleting it from the associated task.*/ +export type ResetIdempotencyKeyOptions = { + scope?: IdempotencyKeyScope; + /** Required if scope is "run" or "attempt" to reconstruct the hash */ + parentRunId?: string; + /** Required if scope is "attempt" to reconstruct the hash */ + attemptNumber?: number; +}; + +/** + * Resets an idempotency key, effectively deleting it from the associated task. + * + * @param taskIdentifier The task identifier (e.g., "my-task") + * @param idempotencyKey The idempotency key to reset. Can be: + * - An `IdempotencyKey` created with `idempotencyKeys.create()` (options are extracted automatically) + * - A string or string array (requires `options.scope` and potentially `options.parentRunId`) + * @param options Options for reconstructing the hash if needed + * @param requestOptions Optional fetch options + * + * @example + * ```typescript + * // Using a key created with idempotencyKeys.create() - options are extracted automatically + * const key = await idempotencyKeys.create("my-key", { scope: "global" }); + * await idempotencyKeys.reset("my-task", key); + * + * // Using a raw string with global scope + * await idempotencyKeys.reset("my-task", "my-key", { scope: "global" }); + * + * // Using a raw string with run scope (requires parentRunId) + * await idempotencyKeys.reset("my-task", "my-key", { + * scope: "run", + * parentRunId: "run_abc123" + * }); + * ``` + */ export async function resetIdempotencyKey( taskIdentifier: string, - idempotencyKey: string, + idempotencyKey: IdempotencyKey | string | string[], + options?: ResetIdempotencyKeyOptions, requestOptions?: ZodFetchOptions ): Promise<{ id: string }> { const client = apiClientManager.clientOrThrow(); - return client.resetIdempotencyKey( - taskIdentifier, - idempotencyKey, - requestOptions - ); + // If the key is already a 64-char hash, use it directly + if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) { + return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); + } + + // Try to extract options from an IdempotencyKey created with idempotencyKeys.create() + const attachedOptions = + typeof idempotencyKey === "string" || idempotencyKey instanceof String + ? getIdempotencyKeyOptions(idempotencyKey as IdempotencyKey) + : undefined; + + const scope = attachedOptions?.scope ?? options?.scope ?? "run"; + const keyArray = Array.isArray(idempotencyKey) + ? idempotencyKey + : [attachedOptions?.key ?? String(idempotencyKey)]; + + // Build scope suffix based on scope type + let scopeSuffix: string[] = []; + switch (scope) { + case "run": { + const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id; + if (!parentRunId) { + throw new Error( + "resetIdempotencyKey: parentRunId is required for 'run' scope when called outside a task context" + ); + } + scopeSuffix = [parentRunId]; + break; + } + case "attempt": { + const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id; + const attemptNumber = options?.attemptNumber ?? taskContext?.ctx?.attempt.number; + if (!parentRunId || attemptNumber === undefined) { + throw new Error( + "resetIdempotencyKey: parentRunId and attemptNumber are required for 'attempt' scope when called outside a task context" + ); + } + scopeSuffix = [parentRunId, attemptNumber.toString()]; + break; + } + } + + // Generate the hash using the same algorithm as createIdempotencyKey + const hash = await generateIdempotencyKey(keyArray.concat(scopeSuffix)); + + return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index abf6bf1124..9080a7f596 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -149,6 +149,14 @@ export const RunTags = z.union([RunTag, RunTag.array()]); export type RunTags = z.infer; +/** Stores the original user-provided idempotency key and scope */ +export const IdempotencyKeyOptionsSchema = z.object({ + key: z.string(), + scope: z.enum(["run", "attempt", "global"]), +}); + +export type IdempotencyKeyOptionsSchema = z.infer; + export const TriggerTaskRequestBody = z.object({ payload: z.any(), context: z.any(), @@ -191,6 +199,8 @@ export const TriggerTaskRequestBody = z.object({ delay: z.string().or(z.coerce.date()).optional(), idempotencyKey: z.string().optional(), idempotencyKeyTTL: z.string().optional(), + /** The original user-provided idempotency key and scope */ + idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), machine: MachinePresetName.optional(), maxAttempts: z.number().int().optional(), maxDuration: z.number().optional(), @@ -240,6 +250,8 @@ export const BatchTriggerTaskItem = z.object({ delay: z.string().or(z.coerce.date()).optional(), idempotencyKey: z.string().optional(), idempotencyKeyTTL: z.string().optional(), + /** The original user-provided idempotency key and scope */ + idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), lockToVersion: z.string().optional(), machine: MachinePresetName.optional(), maxAttempts: z.number().int().optional(), @@ -345,6 +357,8 @@ export const CreateBatchRequestBody = z.object({ resumeParentOnCompletion: z.boolean().optional(), /** Idempotency key for the batch */ idempotencyKey: z.string().optional(), + /** The original user-provided idempotency key and scope */ + idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), }); export type CreateBatchRequestBody = z.infer; diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index 302f4acc17..d721910cb9 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -214,7 +214,10 @@ export const TaskRun = z.object({ isTest: z.boolean().default(false), createdAt: z.coerce.date(), startedAt: z.coerce.date().default(() => new Date()), + /** The user-provided idempotency key (not the hash) */ idempotencyKey: z.string().optional(), + /** The scope of the idempotency key */ + idempotencyKeyScope: z.enum(["run", "attempt", "global"]).optional(), maxAttempts: z.number().optional(), version: z.string().optional(), metadata: z.record(DeserializedJsonSchema).optional(), @@ -374,7 +377,10 @@ export const V3TaskRun = z.object({ isTest: z.boolean().default(false), createdAt: z.coerce.date(), startedAt: z.coerce.date().default(() => new Date()), + /** The user-provided idempotency key (not the hash) */ idempotencyKey: z.string().optional(), + /** The scope of the idempotency key */ + idempotencyKeyScope: z.enum(["run", "attempt", "global"]).optional(), maxAttempts: z.number().optional(), version: z.string().optional(), metadata: z.record(DeserializedJsonSchema).optional(), diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index d8dc511d5b..7b7fa1b979 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -11,6 +11,7 @@ import { defaultRetryOptions, flattenIdempotencyKey, getEnvVar, + getIdempotencyKeyOptions, getSchemaParseFn, InitOutput, lifecycleHooks, @@ -1540,6 +1541,7 @@ async function executeBatchTwoPhase( parentRunId?: string; resumeParentOnCompletion?: boolean; idempotencyKey?: string; + idempotencyKeyOptions?: { key: string; scope: "run" | "attempt" | "global" }; spanParentAsLink?: boolean; }, requestOptions?: TriggerApiRequestOptions @@ -1554,6 +1556,7 @@ async function executeBatchTwoPhase( parentRunId: options.parentRunId, resumeParentOnCompletion: options.resumeParentOnCompletion, idempotencyKey: options.idempotencyKey, + idempotencyKeyOptions: options.idempotencyKeyOptions, }, { spanParentAsLink: options.spanParentAsLink }, requestOptions @@ -1696,6 +1699,7 @@ async function executeBatchTwoPhaseStreaming( parentRunId?: string; resumeParentOnCompletion?: boolean; idempotencyKey?: string; + idempotencyKeyOptions?: { key: string; scope: "run" | "attempt" | "global" }; spanParentAsLink?: boolean; }, requestOptions?: TriggerApiRequestOptions @@ -2058,6 +2062,13 @@ async function* transformSingleTaskBatchItemsStreamForWait( flattenIdempotencyKey([options?.idempotencyKey, `${index}`]) ); + // Process item-specific idempotency key and extract options + const itemIdempotencyKey = await makeIdempotencyKey(item.options?.idempotencyKey); + const finalIdempotencyKey = itemIdempotencyKey ?? batchItemIdempotencyKey; + const idempotencyKeyOptions = itemIdempotencyKey + ? getIdempotencyKeyOptions(itemIdempotencyKey) + : undefined; + yield { index: index++, task: taskIdentifier, @@ -2078,9 +2089,9 @@ async function* transformSingleTaskBatchItemsStreamForWait( maxAttempts: item.options?.maxAttempts, metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, - idempotencyKey: - (await makeIdempotencyKey(item.options?.idempotencyKey)) ?? batchItemIdempotencyKey, + idempotencyKey: finalIdempotencyKey?.toString(), idempotencyKeyTTL: item.options?.idempotencyKeyTTL ?? options?.idempotencyKeyTTL, + idempotencyKeyOptions, machine: item.options?.machine, priority: item.options?.priority, region: item.options?.region, @@ -2104,6 +2115,12 @@ async function trigger_internal( const payloadPacket = await stringifyIO(parsedPayload); + // Process idempotency key and extract options for storage + const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey); + const idempotencyKeyOptions = processedIdempotencyKey + ? getIdempotencyKeyOptions(processedIdempotencyKey) + : undefined; + const handle = await apiClient.triggerTask( id, { @@ -2113,8 +2130,9 @@ async function trigger_internal( concurrencyKey: options?.concurrencyKey, test: taskContext.ctx?.run.isTest, payloadType: payloadPacket.dataType, - idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKey: processedIdempotencyKey?.toString(), idempotencyKeyTTL: options?.idempotencyKeyTTL, + idempotencyKeyOptions, delay: options?.delay, ttl: options?.ttl, tags: options?.tags, @@ -2179,6 +2197,13 @@ async function batchTrigger_internal( flattenIdempotencyKey([options?.idempotencyKey, `${index}`]) ); + // Process item-specific idempotency key and extract options + const itemIdempotencyKey = await makeIdempotencyKey(item.options?.idempotencyKey); + const finalIdempotencyKey = itemIdempotencyKey ?? batchItemIdempotencyKey; + const idempotencyKeyOptions = itemIdempotencyKey + ? getIdempotencyKeyOptions(itemIdempotencyKey) + : undefined; + return { index, task: taskIdentifier, @@ -2198,9 +2223,9 @@ async function batchTrigger_internal( maxAttempts: item.options?.maxAttempts, metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, - idempotencyKey: - (await makeIdempotencyKey(item.options?.idempotencyKey)) ?? batchItemIdempotencyKey, + idempotencyKey: finalIdempotencyKey?.toString(), idempotencyKeyTTL: item.options?.idempotencyKeyTTL ?? options?.idempotencyKeyTTL, + idempotencyKeyOptions, machine: item.options?.machine, priority: item.options?.priority, region: item.options?.region, @@ -2211,6 +2236,12 @@ async function batchTrigger_internal( ); // Execute 2-phase batch + // Process batch-level idempotency key + const batchIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey); + const batchIdempotencyKeyOptions = batchIdempotencyKey + ? getIdempotencyKeyOptions(batchIdempotencyKey) + : undefined; + const response = await tracer.startActiveSpan( name, async (span) => { @@ -2219,7 +2250,8 @@ async function batchTrigger_internal( ndJsonItems, { parentRunId: ctx?.run.id, - idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKey: batchIdempotencyKey?.toString(), + idempotencyKeyOptions: batchIdempotencyKeyOptions, spanParentAsLink: true, // Fire-and-forget: child runs get separate trace IDs }, requestOptions @@ -2266,6 +2298,12 @@ async function batchTrigger_internal( ); // Execute streaming 2-phase batch + // Process batch-level idempotency key + const streamBatchIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey); + const streamBatchIdempotencyKeyOptions = streamBatchIdempotencyKey + ? getIdempotencyKeyOptions(streamBatchIdempotencyKey) + : undefined; + const response = await tracer.startActiveSpan( name, async (span) => { @@ -2274,7 +2312,8 @@ async function batchTrigger_internal( transformedItems, { parentRunId: ctx?.run.id, - idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKey: streamBatchIdempotencyKey?.toString(), + idempotencyKeyOptions: streamBatchIdempotencyKeyOptions, spanParentAsLink: true, // Fire-and-forget: child runs get separate trace IDs }, requestOptions @@ -2332,6 +2371,12 @@ async function triggerAndWait_internal { @@ -2353,8 +2398,9 @@ async function triggerAndWait_internal { @@ -2466,7 +2525,8 @@ async function batchTriggerAndWait_internal { @@ -2528,7 +2594,8 @@ async function batchTriggerAndWait_internal { + // Log the idempotency key from context - should be the user-provided key, not the hash + logger.log("Child task context", { + idempotencyKey: ctx.run.idempotencyKey, + idempotencyKeyScope: ctx.run.idempotencyKeyScope, + runId: ctx.run.id, + }); + + return { + receivedIdempotencyKey: ctx.run.idempotencyKey, + receivedIdempotencyKeyScope: ctx.run.idempotencyKeyScope, + message: payload.message, + }; + }, +}); + + +export const idempotencyKeyOptionsTest = task({ + id: "idempotency-key-options-test", + maxDuration: 60, + run: async (payload: any, { ctx }) => { + logger.log("Testing idempotencyKeyOptions feature (TRI-4352)"); + + // Test 1: Create key with "run" scope (default) + const runScopedKey = await idempotencyKeys.create("my-run-scoped-key"); + logger.log("Created run-scoped key", { key: runScopedKey.toString() }); + + const result1 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Test with run scope" }, + { idempotencyKey: runScopedKey, idempotencyKeyTTL: "60s" } + ); + logger.log("Result 1 (run scope)", { result: result1 }); + + // Test 2: Create key with "global" scope + const globalScopedKey = await idempotencyKeys.create("my-global-scoped-key", { + scope: "global", + }); + logger.log("Created global-scoped key", { key: globalScopedKey.toString() }); + + const result2 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Test with global scope" }, + { idempotencyKey: globalScopedKey, idempotencyKeyTTL: "60s" } + ); + logger.log("Result 2 (global scope)", { result: result2 }); + + // Test 3: Create key with "attempt" scope + const attemptScopedKey = await idempotencyKeys.create("my-attempt-scoped-key", { + scope: "attempt", + }); + logger.log("Created attempt-scoped key", { key: attemptScopedKey.toString() }); + + const result3 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Test with attempt scope" }, + { idempotencyKey: attemptScopedKey, idempotencyKeyTTL: "60s" } + ); + logger.log("Result 3 (attempt scope)", { result: result3 }); + + // Test 4: Create key with array input + const arrayKey = await idempotencyKeys.create(["user", "123", "action"]); + logger.log("Created array key", { key: arrayKey.toString() }); + + const result4 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Test with array key" }, + { idempotencyKey: arrayKey, idempotencyKeyTTL: "60s" } + ); + logger.log("Result 4 (array key)", { result: result4 }); + + return { + results: [ + { scope: "run", idempotencyKey: result1.ok ? result1.output?.receivedIdempotencyKey : null }, + { + scope: "global", + idempotencyKey: result2.ok ? result2.output?.receivedIdempotencyKey : null, + }, + { + scope: "attempt", + idempotencyKey: result3.ok ? result3.output?.receivedIdempotencyKey : null, + }, + { scope: "array", idempotencyKey: result4.ok ? result4.output?.receivedIdempotencyKey : null }, + ], + }; + }, +}); From 6bfa2e05472138512f100291bf7f24c43f2c32ce Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 17 Jan 2026 08:59:33 +0000 Subject: [PATCH 2/8] add changeset --- .changeset/bright-keys-shine.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/bright-keys-shine.md diff --git a/.changeset/bright-keys-shine.md b/.changeset/bright-keys-shine.md new file mode 100644 index 0000000000..d64612ee76 --- /dev/null +++ b/.changeset/bright-keys-shine.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +Expose user-provided idempotency key and scope in task context. `ctx.run.idempotencyKey` now returns the original key passed to `idempotencyKeys.create()` instead of the hash, and `ctx.run.idempotencyKeyScope` shows the scope ("run", "attempt", or "global"). From 3d0c51dba46074544ff263220aa003164e07d61e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 17 Jan 2026 09:20:23 +0000 Subject: [PATCH 3/8] fix clickhouse tests --- internal-packages/clickhouse/src/taskRuns.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal-packages/clickhouse/src/taskRuns.test.ts b/internal-packages/clickhouse/src/taskRuns.test.ts index feecb63a00..ea218d4bc0 100644 --- a/internal-packages/clickhouse/src/taskRuns.test.ts +++ b/internal-packages/clickhouse/src/taskRuns.test.ts @@ -64,6 +64,8 @@ describe("Task Runs V2", () => { "span_1234", // span_id "trace_1234", // trace_id "idempotency_key_1234", // idempotency_key + "my-user-key", // idempotency_key_user + "run", // idempotency_key_scope "1h", // expiration_ttl true, // is_test "1", // _version @@ -189,6 +191,8 @@ describe("Task Runs V2", () => { "538677637f937f54", // span_id "20a28486b0b9f50c647b35e8863e36a5", // trace_id "", // idempotency_key + "", // idempotency_key_user + "", // idempotency_key_scope "", // expiration_ttl true, // is_test "1", // _version @@ -237,6 +241,8 @@ describe("Task Runs V2", () => { "538677637f937f54", // span_id "20a28486b0b9f50c647b35e8863e36a5", // trace_id "", // idempotency_key + "", // idempotency_key_user + "", // idempotency_key_scope "", // expiration_ttl true, // is_test "2", // _version @@ -332,6 +338,8 @@ describe("Task Runs V2", () => { "538677637f937f54", // span_id "20a28486b0b9f50c647b35e8863e36a5", // trace_id "", // idempotency_key + "", // idempotency_key_user + "", // idempotency_key_scope "", // expiration_ttl true, // is_test "1", // _version From 97588351ada1dbfc42c1d07fee9c95d8438d4633 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 17 Jan 2026 09:54:28 +0000 Subject: [PATCH 4/8] extract out the getUserProvidedIdempotencyKey/Scope stuff to core and also added a test for the resetting changes --- .../v3/ApiRetrieveRunPresenter.server.ts | 21 +-- .../app/presenters/v3/SpanPresenter.server.ts | 23 +-- .../services/runsReplicationService.server.ts | 14 +- .../src/engine/systems/runAttemptSystem.ts | 30 +--- .../core/src/v3/serverOnly/idempotencyKeys.ts | 52 ++++++ packages/core/src/v3/serverOnly/index.ts | 1 + .../hello-world/src/trigger/idempotency.ts | 154 +++++++++++++++++- 7 files changed, 221 insertions(+), 74 deletions(-) create mode 100644 packages/core/src/v3/serverOnly/idempotencyKeys.ts diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index 3e3a99cd9a..8d1a312c5d 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -9,6 +9,7 @@ import { logger, } from "@trigger.dev/core/v3"; import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization"; +import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly"; import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database"; import assertNever from "assert-never"; import { API_VERSIONS, CURRENT_API_VERSION, RunStatusUnspecifiedApiVersion } from "~/api/versions"; @@ -18,26 +19,6 @@ import { generatePresignedUrl } from "~/v3/r2.server"; import { tracer } from "~/v3/tracer.server"; import { startSpanWithEnv } from "~/v3/tracing.server"; -/** - * Returns the user-provided idempotency key if available (from idempotencyKeyOptions), - * otherwise falls back to the stored idempotency key (which is the hash). - */ -function getUserProvidedIdempotencyKey(run: { - idempotencyKey: string | null; - idempotencyKeyOptions: unknown; -}): string | undefined { - // If we have the user-provided key options, return the original key - const options = run.idempotencyKeyOptions as { - key?: string; - scope?: string; - } | null; - if (options?.key) { - return options.key; - } - // Fallback to the hash (for runs created before this feature) - return run.idempotencyKey ?? undefined; -} - // Build 'select' object const commonRunSelect = { id: true, diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 82ab7c6d53..b5f1e63bae 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -8,6 +8,7 @@ import { type V3TaskRunContext, } from "@trigger.dev/core/v3"; import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; +import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { logger } from "~/services/logger.server"; import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server"; @@ -229,7 +230,7 @@ export class SpanPresenter extends BasePresenter { isTest: run.isTest, replayedFromTaskRunFriendlyId: run.replayedFromTaskRunFriendlyId, environmentId: run.runtimeEnvironment.id, - idempotencyKey: this.getUserProvidedIdempotencyKey(run), + idempotencyKey: getUserProvidedIdempotencyKey(run), idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt, debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null, schedule: await this.resolveSchedule(run.scheduleId ?? undefined), @@ -645,7 +646,7 @@ export class SpanPresenter extends BasePresenter { createdAt: run.createdAt, tags: run.runTags, isTest: run.isTest, - idempotencyKey: this.getUserProvidedIdempotencyKey(run) ?? undefined, + idempotencyKey: getUserProvidedIdempotencyKey(run) ?? undefined, startedAt: run.startedAt ?? run.createdAt, durationMs: run.usageDurationMs, costInCents: run.costInCents, @@ -706,22 +707,4 @@ export class SpanPresenter extends BasePresenter { return parsedTraceparent?.traceId; } - /** - * Returns the user-provided idempotency key if available (from idempotencyKeyOptions), - * otherwise falls back to the stored idempotency key (which is the hash). - */ - getUserProvidedIdempotencyKey( - run: Pick - ): string | null { - // If we have the user-provided key options, return the original key - const options = run.idempotencyKeyOptions as { - key?: string; - scope?: string; - } | null; - if (options?.key) { - return options.key; - } - // Fallback to the hash (for runs created before this feature) - return run.idempotencyKey; - } } diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 993845e988..9357948566 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -21,6 +21,7 @@ import { import { Logger, type LogLevel } from "@trigger.dev/core/logger"; import { tryCatch } from "@trigger.dev/core/utils"; import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization"; +import { extractIdempotencyKeyUser, getIdempotencyKeyScope } from "@trigger.dev/core/v3/serverOnly"; import { type TaskRun } from "@trigger.dev/database"; import { nanoid } from "nanoid"; import EventEmitter from "node:events"; @@ -891,8 +892,8 @@ export class RunsReplicationService { run.spanId, // span_id run.traceId, // trace_id run.idempotencyKey ?? "", // idempotency_key - this.#extractIdempotencyKeyUser(run), // idempotency_key_user - this.#extractIdempotencyKeyScope(run), // idempotency_key_scope + extractIdempotencyKeyUser(run) ?? "", // idempotency_key_user + getIdempotencyKeyScope(run) ?? "", // idempotency_key_scope run.ttl ?? "", // expiration_ttl run.isTest ?? false, // is_test _version.toString(), // _version @@ -954,15 +955,6 @@ export class RunsReplicationService { return { data: parsedData }; } - #extractIdempotencyKeyUser(run: TaskRun): string { - const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; - return options?.key ?? ""; - } - - #extractIdempotencyKeyScope(run: TaskRun): string { - const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; - return options?.scope ?? ""; - } } export type ConcurrentFlushSchedulerConfig = { diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index fd3ec589ed..6200861637 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -30,6 +30,10 @@ import { TaskRunInternalError, TaskRunSuccessfulExecutionResult, } from "@trigger.dev/core/v3/schemas"; +import { + getIdempotencyKeyScope, + getUserProvidedIdempotencyKey, +} from "@trigger.dev/core/v3/serverOnly"; import { parsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; import { $transaction, @@ -262,8 +266,8 @@ export class RunAttemptSystem { isTest: run.isTest, createdAt: run.createdAt, startedAt: run.startedAt ?? run.createdAt, - idempotencyKey: this.#getUserProvidedIdempotencyKey(run) ?? undefined, - idempotencyKeyScope: this.#getIdempotencyKeyScope(run), + idempotencyKey: getUserProvidedIdempotencyKey(run) ?? undefined, + idempotencyKeyScope: getIdempotencyKeyScope(run), maxAttempts: run.maxAttempts ?? undefined, version: run.taskVersion ?? "unknown", maxDuration: run.maxDurationInSeconds ?? undefined, @@ -573,8 +577,8 @@ export class RunAttemptSystem { createdAt: updatedRun.createdAt, tags: updatedRun.runTags, isTest: updatedRun.isTest, - idempotencyKey: this.#getUserProvidedIdempotencyKey(updatedRun) ?? undefined, - idempotencyKeyScope: this.#getIdempotencyKeyScope(updatedRun), + idempotencyKey: getUserProvidedIdempotencyKey(updatedRun) ?? undefined, + idempotencyKeyScope: getIdempotencyKeyScope(updatedRun), startedAt: updatedRun.startedAt ?? updatedRun.createdAt, maxAttempts: updatedRun.maxAttempts ?? undefined, version: updatedRun.taskVersion ?? "unknown", @@ -1919,24 +1923,6 @@ export class RunAttemptSystem { }; } - #getUserProvidedIdempotencyKey( - run: { idempotencyKey: string | null; idempotencyKeyOptions: unknown } - ): string | null { - const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; - // Return user-provided key if available, otherwise fall back to the hash - return options?.key ?? run.idempotencyKey; - } - - #getIdempotencyKeyScope( - run: { idempotencyKeyOptions: unknown } - ): "run" | "attempt" | "global" | undefined { - const options = run.idempotencyKeyOptions as { key?: string; scope?: string } | null; - const scope = options?.scope; - if (scope === "run" || scope === "attempt" || scope === "global") { - return scope; - } - return undefined; - } } export function safeParseGitMeta(git: unknown): GitMeta | undefined { diff --git a/packages/core/src/v3/serverOnly/idempotencyKeys.ts b/packages/core/src/v3/serverOnly/idempotencyKeys.ts new file mode 100644 index 0000000000..4bc1856f30 --- /dev/null +++ b/packages/core/src/v3/serverOnly/idempotencyKeys.ts @@ -0,0 +1,52 @@ +import { IdempotencyKeyOptionsSchema } from "../schemas/api.js"; + +/** + * Safely parses idempotencyKeyOptions from a database record and extracts the user-provided key. + * Returns the user-provided key if valid options exist, otherwise falls back to the hash. + * + * @param run - Object containing idempotencyKey (the hash) and idempotencyKeyOptions (JSON from DB) + * @returns The user-provided key, the hash as fallback, or null if neither exists + */ +export function getUserProvidedIdempotencyKey(run: { + idempotencyKey: string | null; + idempotencyKeyOptions: unknown; +}): string | null { + const parsed = IdempotencyKeyOptionsSchema.safeParse(run.idempotencyKeyOptions); + if (parsed.success) { + return parsed.data.key; + } + return run.idempotencyKey; +} + +/** + * Safely parses idempotencyKeyOptions and extracts the scope. + * + * @param run - Object containing idempotencyKeyOptions (JSON from DB) + * @returns The scope if valid options exist, otherwise undefined + */ +export function getIdempotencyKeyScope(run: { + idempotencyKeyOptions: unknown; +}): "run" | "attempt" | "global" | undefined { + const parsed = IdempotencyKeyOptionsSchema.safeParse(run.idempotencyKeyOptions); + if (parsed.success) { + return parsed.data.scope; + } + return undefined; +} + +/** + * Extracts just the user-provided key from idempotencyKeyOptions, without falling back to the hash. + * Useful for ClickHouse replication where we want to store only the explicit user key. + * + * @param run - Object containing idempotencyKeyOptions (JSON from DB) + * @returns The user-provided key if valid options exist, otherwise undefined + */ +export function extractIdempotencyKeyUser(run: { + idempotencyKeyOptions: unknown; +}): string | undefined { + const parsed = IdempotencyKeyOptionsSchema.safeParse(run.idempotencyKeyOptions); + if (parsed.success) { + return parsed.data.key; + } + return undefined; +} diff --git a/packages/core/src/v3/serverOnly/index.ts b/packages/core/src/v3/serverOnly/index.ts index d4a74633dc..8f56f0e330 100644 --- a/packages/core/src/v3/serverOnly/index.ts +++ b/packages/core/src/v3/serverOnly/index.ts @@ -8,3 +8,4 @@ export * from "./jumpHash.js"; export * from "../apiClient/version.js"; export * from "./placementTags.js"; export * from "./resourceMonitor.js"; +export * from "./idempotencyKeys.js"; diff --git a/references/hello-world/src/trigger/idempotency.ts b/references/hello-world/src/trigger/idempotency.ts index 519fc118a8..f45e6af14a 100644 --- a/references/hello-world/src/trigger/idempotency.ts +++ b/references/hello-world/src/trigger/idempotency.ts @@ -1,4 +1,4 @@ -import { batch, idempotencyKeys, logger, task, timeout, usage, wait } from "@trigger.dev/sdk/v3"; +import { batch, idempotencyKeys, logger, runs, task, timeout, usage, wait } from "@trigger.dev/sdk/v3"; import { setTimeout } from "timers/promises"; import { childTask } from "./example.js"; @@ -388,3 +388,155 @@ export const idempotencyKeyOptionsTest = task({ }; }, }); + +// Test task for verifying idempotencyKeys.reset works with the new API (TRI-4352) +export const idempotencyKeyResetTest = task({ + id: "idempotency-key-reset-test", + maxDuration: 120, + run: async (payload: any, { ctx }) => { + logger.log("Testing idempotencyKeys.reset feature (TRI-4352)"); + + const testResults: Array<{ + test: string; + success: boolean; + details: Record; + }> = []; + + // Test 1: Reset using IdempotencyKey object (options extracted automatically) + { + const key = await idempotencyKeys.create("reset-test-key-1", { scope: "global" }); + logger.log("Test 1: Created global-scoped key", { key: key.toString() }); + + // First trigger - should create a new run + const result1 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "First trigger" }, + { idempotencyKey: key, idempotencyKeyTTL: "300s" } + ); + const firstRunId = result1.ok ? result1.id : null; + logger.log("Test 1: First trigger", { runId: firstRunId }); + + // Second trigger - should be deduplicated (same run ID) + const result2 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Second trigger (should dedupe)" }, + { idempotencyKey: key, idempotencyKeyTTL: "300s" } + ); + const secondRunId = result2.ok ? result2.id : null; + logger.log("Test 1: Second trigger (dedupe check)", { runId: secondRunId }); + + const wasDeduplicated = firstRunId === secondRunId; + + // Reset the idempotency key using the IdempotencyKey object + logger.log("Test 1: Resetting idempotency key using IdempotencyKey object"); + await idempotencyKeys.reset("idempotency-key-options-child", key); + + // Third trigger - should create a NEW run after reset + const result3 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Third trigger (after reset)" }, + { idempotencyKey: key, idempotencyKeyTTL: "300s" } + ); + const thirdRunId = result3.ok ? result3.id : null; + logger.log("Test 1: Third trigger (after reset)", { runId: thirdRunId }); + + const wasResetSuccessful = thirdRunId !== firstRunId && thirdRunId !== null; + + testResults.push({ + test: "Reset with IdempotencyKey object (global scope)", + success: wasDeduplicated && wasResetSuccessful, + details: { + firstRunId, + secondRunId, + thirdRunId, + wasDeduplicated, + wasResetSuccessful, + }, + }); + } + + // Test 2: Reset using raw string with scope option + { + const keyString = "reset-test-key-2"; + const key = await idempotencyKeys.create(keyString, { scope: "global" }); + logger.log("Test 2: Created global-scoped key from string", { key: key.toString() }); + + // First trigger + const result1 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "First trigger (raw string test)" }, + { idempotencyKey: key, idempotencyKeyTTL: "300s" } + ); + const firstRunId = result1.ok ? result1.id : null; + logger.log("Test 2: First trigger", { runId: firstRunId }); + + // Reset using raw string + scope option + logger.log("Test 2: Resetting idempotency key using raw string + scope"); + await idempotencyKeys.reset("idempotency-key-options-child", keyString, { scope: "global" }); + + // Second trigger - should create a NEW run after reset + const result2 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Second trigger (after reset with raw string)" }, + { idempotencyKey: key, idempotencyKeyTTL: "300s" } + ); + const secondRunId = result2.ok ? result2.id : null; + logger.log("Test 2: Second trigger (after reset)", { runId: secondRunId }); + + const wasResetSuccessful = secondRunId !== firstRunId && secondRunId !== null; + + testResults.push({ + test: "Reset with raw string + scope option (global scope)", + success: wasResetSuccessful, + details: { + firstRunId, + secondRunId, + wasResetSuccessful, + }, + }); + } + + // Test 3: Reset with run scope (uses current run context) + { + const key = await idempotencyKeys.create("reset-test-key-3", { scope: "run" }); + logger.log("Test 3: Created run-scoped key", { key: key.toString() }); + + // First trigger + const result1 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "First trigger (run scope)" }, + { idempotencyKey: key, idempotencyKeyTTL: "300s" } + ); + const firstRunId = result1.ok ? result1.id : null; + logger.log("Test 3: First trigger", { runId: firstRunId }); + + // Reset using IdempotencyKey (run scope - should use current run context) + logger.log("Test 3: Resetting idempotency key with run scope"); + await idempotencyKeys.reset("idempotency-key-options-child", key); + + // Second trigger - should create a NEW run after reset + const result2 = await idempotencyKeyOptionsChild.triggerAndWait( + { message: "Second trigger (after reset, run scope)" }, + { idempotencyKey: key, idempotencyKeyTTL: "300s" } + ); + const secondRunId = result2.ok ? result2.id : null; + logger.log("Test 3: Second trigger (after reset)", { runId: secondRunId }); + + const wasResetSuccessful = secondRunId !== firstRunId && secondRunId !== null; + + testResults.push({ + test: "Reset with IdempotencyKey object (run scope)", + success: wasResetSuccessful, + details: { + firstRunId, + secondRunId, + wasResetSuccessful, + parentRunId: ctx.run.id, + }, + }); + } + + // Summary + const allPassed = testResults.every((r) => r.success); + logger.log("Test summary", { allPassed, testResults }); + + return { + allPassed, + testResults, + }; + }, +}); From 9b431a0660e29253d072533cf8315743f195006b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 19 Jan 2026 17:03:59 +0000 Subject: [PATCH 5/8] Improve the display of the idempotency section in the run details inspector --- .../app/presenters/v3/SpanPresenter.server.ts | 32 +++- ...am.runs.$runParam.idempotencyKey.reset.tsx | 52 ++---- .../route.tsx | 153 ++++++++++-------- 3 files changed, 125 insertions(+), 112 deletions(-) diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index b5f1e63bae..f6204f4a69 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -8,7 +8,10 @@ import { type V3TaskRunContext, } from "@trigger.dev/core/v3"; import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; -import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly"; +import { + getIdempotencyKeyScope, + getUserProvidedIdempotencyKey, +} from "@trigger.dev/core/v3/serverOnly"; import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { logger } from "~/services/logger.server"; import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server"; @@ -232,6 +235,8 @@ export class SpanPresenter extends BasePresenter { environmentId: run.runtimeEnvironment.id, idempotencyKey: getUserProvidedIdempotencyKey(run), idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt, + idempotencyKeyScope: getIdempotencyKeyScope(run), + idempotencyKeyStatus: this.getIdempotencyKeyStatus(run), debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null, schedule: await this.resolveSchedule(run.scheduleId ?? undefined), queue: { @@ -277,6 +282,30 @@ export class SpanPresenter extends BasePresenter { }; } + private getIdempotencyKeyStatus(run: { + idempotencyKey: string | null; + idempotencyKeyExpiresAt: Date | null; + idempotencyKeyOptions: unknown; + }): "active" | "inactive" | "expired" | undefined { + // No idempotency configured if no scope exists + const scope = getIdempotencyKeyScope(run); + if (!scope) { + return undefined; + } + + // Check if expired first (takes precedence) + if (run.idempotencyKeyExpiresAt && run.idempotencyKeyExpiresAt < new Date()) { + return "expired"; + } + + // Check if reset (hash is null but options exist) + if (run.idempotencyKey === null) { + return "inactive"; + } + + return "active"; + } + async resolveSchedule(scheduleId?: string) { if (!scheduleId) { return; @@ -706,5 +735,4 @@ export class SpanPresenter extends BasePresenter { return parsedTraceparent?.traceId; } - } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset.tsx index 8c2ba59aff..614b668f91 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset.tsx @@ -1,32 +1,16 @@ -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 } from "~/models/message.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"; -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); - } + const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params); try { - const { taskIdentifier } = submission.value; - const taskRun = await prisma.taskRun.findFirst({ where: { friendlyId: runParam, @@ -54,21 +38,11 @@ export const action: ActionFunction = async ({ request, params }) => { }); if (!taskRun) { - submission.error = { runParam: ["Run not found"] }; - return json(submission); + return jsonWithErrorMessage({}, request, "Run not found"); } 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); + return jsonWithErrorMessage({}, request, "This run does not have an idempotency key"); } const environment = await prisma.runtimeEnvironment.findUnique({ @@ -85,22 +59,18 @@ export const action: ActionFunction = async ({ request, params }) => { }); if (!environment) { - return jsonWithErrorMessage( - submission, - request, - "Environment not found" - ); + return jsonWithErrorMessage({}, request, "Environment not found"); } const service = new ResetIdempotencyKeyService(); - await service.call(taskRun.idempotencyKey, taskIdentifier, { + await service.call(taskRun.idempotencyKey, taskRun.taskIdentifier, { ...environment, organizationId: environment.project.organizationId, organization: environment.project.organization, }); - return json({ success: true }); + return jsonWithSuccessMessage({}, request, "Idempotency key reset successfully"); } catch (error) { if (error instanceof Error) { logger.error("Failed to reset idempotency key", { @@ -110,15 +80,11 @@ export const action: ActionFunction = async ({ request, params }) => { stack: error.stack, }, }); - return jsonWithErrorMessage( - submission, - request, - `Failed to reset idempotency key: ${error.message}` - ); + return jsonWithErrorMessage({}, 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)}` ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 2365e74904..137cead53d 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1,6 +1,8 @@ import { ArrowPathIcon, + BookOpenIcon, CheckIcon, + ClockIcon, CloudArrowDownIcon, EnvelopeIcon, QueueListIcon, @@ -159,7 +161,8 @@ export function SpanView({ return null; } - if (fetcher.state !== "idle" || fetcher.data === undefined) { + // Only show loading spinner when there's no data yet, not during revalidation + if (fetcher.data === undefined) { return (
(); - // 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) => ( - - ), - { - duration: 5000, - } - ); - } - } - }, [resetFetcher.data, resetFetcher.state]); - return (
@@ -443,6 +420,12 @@ function RunBody({ /> + + Run ID + + + + {run.relationships.root ? ( run.relationships.root.isParent ? ( @@ -581,38 +564,45 @@ function RunBody({ )} - Idempotency - -
-
- {run.idempotencyKey ? ( - - ) : ( -
- )} - {run.idempotencyKey && ( -
- Expires:{" "} - {run.idempotencyKeyExpiresAt ? ( - - ) : ( - "–" - )} -
- )} -
- {run.idempotencyKey && ( + +
+ + Idempotency + + + Idempotency keys prevent duplicate task runs. If you trigger a task + with the same key twice, the second request returns the original run. + + + Scope: global applies across all + runs, run is unique to a parent run, and{" "} + attempt is unique to a specific attempt. + + + Status: Active means duplicates are + blocked, Expired means the TTL has passed, and{" "} + Inactive means the key was reset or cleared. + + + Read docs + +
+ } + /> + + {run.idempotencyKeyStatus === "active" ? ( -
+ + + {run.idempotencyKeyStatus ? ( + <> +
+ Key: + {run.idempotencyKey ? ( + + ) : ( + "–" + )} +
+
+ Scope: + {run.idempotencyKeyScope ?? "–"} +
+
+ + {run.idempotencyKeyStatus === "expired" ? "Expired: " : "Expires: "} + + {run.idempotencyKeyExpiresAt ? ( + + ) : ( + "–" + )} +
+ + ) : ( + "–" + )}
@@ -859,18 +890,6 @@ function RunBody({ : "–"} - - Run ID - - - - - - Internal ID - - - - Run Engine {run.engine} From d797cc00fda84772589e786fddd35112e03c263a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 19 Jan 2026 17:47:29 +0000 Subject: [PATCH 6/8] Adds a combo-button for viewing/downloading logs --- .../route.tsx | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 137cead53d..19b8c41dd7 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1,7 +1,9 @@ import { ArrowPathIcon, + ArrowRightIcon, BookOpenIcon, CheckIcon, + ChevronUpIcon, ClockIcon, CloudArrowDownIcon, EnvelopeIcon, @@ -30,9 +32,14 @@ import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Popover, + PopoverContent, + PopoverMenuItem, + PopoverTrigger, +} from "~/components/primitives/Popover"; import * as Property from "~/components/primitives/PropertyTable"; import { Spinner } from "~/components/primitives/Spinner"; -import { toast } from "sonner"; import { Table, TableBody, @@ -44,7 +51,6 @@ 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"; @@ -67,6 +73,7 @@ import { useHasAdminAccess } from "~/hooks/useUser"; import { redirectWithErrorMessage } from "~/models/message.server"; import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server"; import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import { @@ -84,11 +91,10 @@ import { v3SpanParamsSchema, } from "~/utils/pathBuilder"; import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents"; -import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; -import { requireUserId } from "~/services/session.server"; import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types"; +import { type action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset"; 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"; +import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -312,7 +318,7 @@ function RunBody({ const resetFetcher = useTypedFetcher(); return ( -
+
-
+
{run.friendlyId !== runParam && (
-
+
{run.logsDeletedAt === null ? ( - <> +
View logs - - Download logs - - + + + + + + + + + +
) : null}
From e076708a5bcb78fb94ec872a14151e072f151035 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 19 Jan 2026 18:11:44 +0000 Subject: [PATCH 7/8] Nicely formatted idempotency tooltip information --- .../route.tsx | 102 +++++++++++++++--- 1 file changed, 85 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 19b8c41dd7..8a0b2f8cd0 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -7,7 +7,10 @@ import { ClockIcon, CloudArrowDownIcon, EnvelopeIcon, + GlobeAltIcon, + KeyIcon, QueueListIcon, + SignalIcon, } from "@heroicons/react/20/solid"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { @@ -576,21 +579,85 @@ function RunBody({ Idempotency - - Idempotency keys prevent duplicate task runs. If you trigger a task - with the same key twice, the second request returns the original run. - - - Scope: global applies across all - runs, run is unique to a parent run, and{" "} - attempt is unique to a specific attempt. - - - Status: Active means duplicates are - blocked, Expired means the TTL has passed, and{" "} - Inactive means the key was reset or cleared. - +
+
+
+ + Idempotency keys +
+ + Prevent duplicate task runs. If you trigger a task with the same + key twice, the second request returns the original run. + +
+
+
+ + Scope +
+
    +
  • + + + Global:{" "} + + applies across all runs + + +
  • +
  • + + + Run:{" "} + + unique to a parent run + + +
  • +
  • + + + Attempt:{" "} + + unique to a specific attempt + + +
  • +
+
+
+
+ + Status +
+
    +
  • + + + Active:{" "} + + duplicates are blocked + + +
  • +
  • + + + Expired:{" "} + the TTL has passed + +
  • +
  • + + + Inactive:{" "} + + the key was reset or cleared + + +
  • +
+
{run.idempotencyKeyStatus ? ( - <> +
Key: {run.idempotencyKey ? ( @@ -638,6 +705,7 @@ function RunBody({ value={run.idempotencyKey} copyValue={run.idempotencyKey} asChild + className="max-h-5" /> ) : ( "–" @@ -657,7 +725,7 @@ function RunBody({ "–" )}
- +
) : ( "–" )} From 0487caedf81f03266533918fb70d256edda31244 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 19 Jan 2026 21:57:04 +0000 Subject: [PATCH 8/8] Simpler tool tip text layout --- .../route.tsx | 66 +++---------------- 1 file changed, 10 insertions(+), 56 deletions(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 8a0b2f8cd0..6c6222e7c3 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -595,68 +595,22 @@ function RunBody({ Scope
-
    -
  • - - - Global:{" "} - - applies across all runs - - -
  • -
  • - - - Run:{" "} - - unique to a parent run - - -
  • -
  • - - - Attempt:{" "} - - unique to a specific attempt - - -
  • -
+
+
Global: applies across all runs
+
Run: unique to a parent run
+
Attempt: unique to a specific attempt
+
Status
-
    -
  • - - - Active:{" "} - - duplicates are blocked - - -
  • -
  • - - - Expired:{" "} - the TTL has passed - -
  • -
  • - - - Inactive:{" "} - - the key was reset or cleared - - -
  • -
+
+
Active: duplicates are blocked
+
Expired: the TTL has passed
+
Inactive: the key was reset or cleared
+