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..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
@@ -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,45 @@ function ScheduledTaskForm({
)}
{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
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)