From c9e78ec25aa01de0c2074ace20d9882d65198652 Mon Sep 17 00:00:00 2001
From: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 15:20:15 +0000
Subject: [PATCH 1/2] feat: add region selector to test and replay task UI
Allow users to select which region to run a task in when testing or
replaying from the webapp dashboard. Previously this was only possible
via the SDK's trigger() options. The region selector appears in the
options panel alongside machine preset, and only shows when multiple
regions are available to the project.
Closes #3016
---
.../components/runs/v3/ReplayRunDialog.tsx | 30 ++++
.../route.tsx | 142 ++++++++++++++++--
.../resources.taskruns.$runParam.replay.ts | 20 ++-
.../app/v3/services/replayTaskRun.server.ts | 2 +-
.../webapp/app/v3/services/testTask.server.ts | 2 +
apps/webapp/app/v3/testTask.ts | 1 +
6 files changed, 178 insertions(+), 19 deletions(-)
diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
index 9192020e1bc..e42a2122abe 100644
--- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
+++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
@@ -201,6 +201,7 @@ function ReplayForm({
tags,
version,
machine,
+ region,
prioritySeconds,
},
] = useForm({
@@ -357,6 +358,35 @@ function ReplayForm({
)}
{version.error}
+ {replayData.regions.length > 1 && (
+
+
+ Region
+
+
+ {replayData.regions.map((r) => (
+
+ {r.description ? `${r.name} — ${r.description}` : r.name}
+ {r.isDefault ? " (default)" : ""}
+
+ ))}
+
+ {replayData.disableVersionSelection ? (
+ Region is not available in the development environment.
+ ) : (
+ Overrides the region for this run.
+ )}
+ {region.error}
+
+ )}
Queue
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
index d452a757136..5c654b63202 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
@@ -54,7 +54,7 @@ import {
TestTaskPresenter,
} from "~/presenters/v3/TestTaskPresenter.server";
import { logger } from "~/services/logger.server";
-import { requireUserId } from "~/services/session.server";
+import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
import { TestTaskService } from "~/v3/services/testTask.server";
@@ -75,14 +75,15 @@ import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
+import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
+ const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params);
- const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
@@ -90,7 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
- const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
@@ -100,14 +101,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new TestTaskPresenter($replica, clickhouseClient);
try {
- const result = await presenter.call({
- userId,
- projectId: project.id,
- taskIdentifier: taskParam,
- environment: environment,
- });
-
- return typedjson(result);
+ const [result, regionsResult] = await Promise.all([
+ presenter.call({
+ userId: user.id,
+ projectId: project.id,
+ taskIdentifier: taskParam,
+ environment: environment,
+ }),
+ new RegionsPresenter().call({
+ userId: user.id,
+ projectSlug: projectParam,
+ isAdmin: user.admin || user.isImpersonating,
+ }),
+ ]);
+
+ return typedjson({ ...result, regions: regionsResult.regions });
} catch (error) {
return redirectWithErrorMessage(
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
@@ -118,15 +126,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const action: ActionFunction = async ({ request, params }) => {
- const userId = await requireUserId(request);
+ const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = v3TaskParamsSchema.parse(params);
- const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
return redirectBackWithErrorMessage(request, "Project not found");
}
- const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
return redirectBackWithErrorMessage(request, "Environment not found");
@@ -290,6 +298,7 @@ export default function Page() {
templates={result.taskRunTemplates}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
+ regions={result.regions}
/>
);
}
@@ -304,6 +313,7 @@ export default function Page() {
possibleTimezones={result.possibleTimezones}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
+ regions={result.regions}
/>
);
}
@@ -324,6 +334,7 @@ function StandardTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
+ regions,
}: {
task: StandardTaskResult["task"];
queues: Required["queue"][];
@@ -332,6 +343,7 @@ function StandardTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
+ regions: Region[];
}) {
const environment = useEnvironment();
const { value, replace } = useSearchParams();
@@ -373,6 +385,12 @@ function StandardTaskForm({
);
const [queueValue, setQueueValue] = useState(lastRun?.queue);
const [machineValue, setMachineValue] = useState(lastRun?.machinePreset);
+ const isDev = environment.type === "DEVELOPMENT";
+ const defaultRegion = regions.find((r) => r.isDefault);
+ const [regionValue, setRegionValue] = useState(
+ isDev ? undefined : defaultRegion?.name
+ );
+
const [maxAttemptsValue, setMaxAttemptsValue] = useState(
lastRun?.maxAttempts
);
@@ -381,6 +399,12 @@ function StandardTaskForm({
);
const [tagsValue, setTagsValue] = useState(lastRun?.runTags ?? []);
+ const regionItems = regions.map((r) => ({
+ value: r.name,
+ label: r.description ? `${r.name} — ${r.description}` : r.name,
+ isDefault: r.isDefault,
+ }));
+
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -409,6 +433,7 @@ function StandardTaskForm({
tags,
version,
machine,
+ region,
prioritySeconds,
},
] = useForm({
@@ -580,6 +605,45 @@ function StandardTaskForm({
)}
{version.error}
+ {regionItems.length > 1 && (
+
+
+ Region
+
+ {/* Our Select primitive uses Ariakit under the hood, which treats
+ value={undefined} as uncontrolled, keeping stale internal state when
+ switching environments. The key forces a remount so it reinitializes
+ with the correct defaultValue. */}
+ {
+ if (Array.isArray(e)) return;
+ setRegionValue(e);
+ }}
+ disabled={isDev}
+ >
+ {regionItems.map((r) => (
+
+ {r.label}
+ {r.isDefault ? " (default)" : ""}
+
+ ))}
+
+ {isDev ? (
+ Region is not available in the development environment.
+ ) : (
+ Overrides the region for this run.
+ )}
+ {region.error}
+
+ )}
Queue
@@ -803,6 +867,7 @@ function ScheduledTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
+ regions,
}: {
task: ScheduledTaskResult["task"];
runs: ScheduledRun[];
@@ -812,6 +877,7 @@ function ScheduledTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
+ regions: Region[];
}) {
const environment = useEnvironment();
@@ -833,6 +899,12 @@ function ScheduledTaskForm({
);
const [queueValue, setQueueValue] = useState(lastRun?.queue);
const [machineValue, setMachineValue] = useState(lastRun?.machinePreset);
+ const isDev = environment.type === "DEVELOPMENT";
+ const defaultRegion = regions.find((r) => r.isDefault);
+ const [regionValue, setRegionValue] = useState(
+ isDev ? undefined : defaultRegion?.name
+ );
+
const [maxAttemptsValue, setMaxAttemptsValue] = useState(
lastRun?.maxAttempts
);
@@ -843,6 +915,12 @@ function ScheduledTaskForm({
const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false);
+ const regionItems = regions.map((r) => ({
+ value: r.name,
+ label: r.description ? `${r.name} — ${r.description}` : r.name,
+ isDefault: r.isDefault,
+ }));
+
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -879,6 +957,7 @@ function ScheduledTaskForm({
tags,
version,
machine,
+ region,
prioritySeconds,
},
] = useForm({
@@ -1101,6 +1180,39 @@ function ScheduledTaskForm({
)}
{version.error}
+ {regionItems.length > 1 && (
+
+
+ Region
+
+ {
+ if (Array.isArray(e)) return;
+ setRegionValue(e);
+ }}
+ disabled={isDev}
+ >
+ {regionItems.map((r) => (
+
+ {r.label}
+ {r.isDefault ? " (default)" : ""}
+
+ ))}
+
+ {isDev ? (
+ Region is not available in the development environment.
+ ) : (
+ Overrides the region for this run.
+ )}
+ {region.error}
+
+ )}
Queue
diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
index 5448a99483b..eba924f409b 100644
--- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
+++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
@@ -7,7 +7,7 @@ import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
-import { requireUserId } from "~/services/session.server";
+import { requireUser } from "~/services/session.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
@@ -15,6 +15,7 @@ import parseDuration from "parse-duration";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { queueTypeFromType } from "~/presenters/v3/QueueRetrievePresenter.server";
import { ReplayRunData } from "~/v3/replayTask";
+import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
const ParamSchema = z.object({
runParam: z.string(),
@@ -25,7 +26,8 @@ const QuerySchema = z.object({
});
export async function loader({ request, params }: LoaderFunctionArgs) {
- const userId = await requireUserId(request);
+ const user = await requireUser(request);
+ const userId = user.id;
const { runParam } = ParamSchema.parse(params);
const { environmentIdOverride } = QuerySchema.parse(
Object.fromEntries(new URL(request.url).searchParams)
@@ -42,6 +44,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
+ workerQueue: true,
ttl: true,
idempotencyKey: true,
runTags: true,
@@ -49,6 +52,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
taskIdentifier: true,
project: {
select: {
+ slug: true,
environments: {
select: {
id: true,
@@ -108,13 +112,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers.at(0)?.engine === "V1";
- const payload = await prettyPrintPacket(run.payload, run.payloadType);
+ const [payload, regionsResult] = await Promise.all([
+ prettyPrintPacket(run.payload, run.payloadType),
+ new RegionsPresenter().call({
+ userId,
+ projectSlug: run.project.slug,
+ isAdmin: user.admin || user.isImpersonating,
+ }),
+ ]);
return typedjson({
concurrencyKey: run.concurrencyKey,
maxAttempts: run.maxAttempts,
maxDurationSeconds: run.maxDurationInSeconds,
machinePreset: run.machinePreset,
+ region: environment.type === "DEVELOPMENT" ? undefined : run.workerQueue,
+ regions: regionsResult.regions,
ttlSeconds: run.ttl ? parseDuration(run.ttl, "s") ?? undefined : undefined,
idempotencyKey: run.idempotencyKey,
runTags: run.runTags,
@@ -194,6 +207,7 @@ export const action: ActionFunction = async ({ request, params }) => {
maxAttempts: submission.value.maxAttempts,
maxDurationSeconds: submission.value.maxDurationSeconds,
machine: submission.value.machine,
+ region: submission.value.region,
delaySeconds: submission.value.delaySeconds,
idempotencyKey: submission.value.idempotencyKey,
idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts
index ceaae95398f..a5018f51c57 100644
--- a/apps/webapp/app/v3/services/replayTaskRun.server.ts
+++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts
@@ -64,7 +64,7 @@ export class ReplayTaskRunService extends BaseService {
existingTaskRun.engine === "V1" ||
existingEnvironment.type === "DEVELOPMENT" ||
authenticatedEnvironment.type === "DEVELOPMENT";
- const region = ignoreRegion ? undefined : existingTaskRun.workerQueue;
+ const region = ignoreRegion ? undefined : overrideOptions.region ?? existingTaskRun.workerQueue;
try {
const taskQueue = await this._prisma.taskQueue.findFirst({
diff --git a/apps/webapp/app/v3/services/testTask.server.ts b/apps/webapp/app/v3/services/testTask.server.ts
index d999fed0464..0b64367f600 100644
--- a/apps/webapp/app/v3/services/testTask.server.ts
+++ b/apps/webapp/app/v3/services/testTask.server.ts
@@ -28,6 +28,7 @@ export class TestTaskService extends BaseService {
maxDuration: data.maxDurationSeconds,
tags: data.tags,
machine: data.machine,
+ region: data.region,
lockToVersion: data.version === "latest" ? undefined : data.version,
priority: data.prioritySeconds,
},
@@ -66,6 +67,7 @@ export class TestTaskService extends BaseService {
maxDuration: data.maxDurationSeconds,
tags: data.tags,
machine: data.machine,
+ region: data.region,
lockToVersion: data.version === "latest" ? undefined : data.version,
priority: data.prioritySeconds,
},
diff --git a/apps/webapp/app/v3/testTask.ts b/apps/webapp/app/v3/testTask.ts
index bf167ed1702..54db301cdef 100644
--- a/apps/webapp/app/v3/testTask.ts
+++ b/apps/webapp/app/v3/testTask.ts
@@ -22,6 +22,7 @@ export const RunOptionsData = z.object({
concurrencyKey: z.string().optional(),
maxAttempts: z.number().min(1).optional(),
machine: MachinePresetName.optional(),
+ region: z.string().optional(),
maxDurationSeconds: z
.number()
.min(0)
From c259bc1174c7890c38321eaaa915f8b69d530032 Mon Sep 17 00:00:00 2001
From: nicktrn <55853254+nicktrn@users.noreply.github.com>
Date: Tue, 17 Feb 2026 21:31:13 +0000
Subject: [PATCH 2/2] fix: add missing dev environment guards to
ScheduledTaskForm region select
---
.../route.tsx | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
index 5c654b63202..a87cc4530d2 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
@@ -1185,14 +1185,20 @@ function ScheduledTaskForm({
Region
+ {/* Our Select primitive uses Ariakit under the hood, which treats
+ value={undefined} as uncontrolled, keeping stale internal state when
+ switching environments. The key forces a remount so it reinitializes
+ with the correct defaultValue. */}
{
+ defaultValue={isDev ? undefined : defaultRegion?.name}
+ value={isDev ? undefined : regionValue}
+ setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}