fix(webapp): accept all valid run and batch IDs in dashboard filters (#4152)

## Summary

The **Run ID** and **Batch ID** filters on the runs list, batches list,
and logs view rejected valid IDs. The input showed an error and the
**Apply** button stayed disabled, so filtering by an affected run or
batch ID from the dashboard was impossible.

The filter validators hard-coded exact friendly-id character lengths.
Friendly IDs come in three generations that all still exist in the data
(`<prefix>_` plus a 21-char nanoid, a 25-char cuid, or a 27-char ksuid),
and the hard-coded lengths never covered all three at once.

## Fix

All the ID filter validators (run, batch, waitpoint, schedule) now share
one helper, `makeFriendlyIdValidator`
(`apps/webapp/app/utils/friendlyId.ts`), which validates by prefix plus
a base62 body of any known generator length (21 / 25 / 27). The cuid and
ksuid lengths are sourced from core so the helper tracks any future
change to those formats. Unit tests assert it accepts the output of the
real id generators and rejects malformed input.

Downstream was already unaffected: run/batch route params and
URL-applied filters use unconstrained validation, so only the manual
filter inputs needed the fix.
This commit is contained in:
Daniel Sutton
2026-07-04 19:57:32 +01:00
committed by GitHub
parent 962bc48738
commit 119189f31a
8 changed files with 138 additions and 29 deletions
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
The Run ID and Batch ID filters on the runs, batches, and logs pages now accept every valid ID format, fixing a case where valid IDs were rejected and the Apply button stayed disabled.
@@ -9,8 +9,10 @@ import { Label } from "~/components/primitives/Label";
import { SelectPopover, SelectProvider, SelectTrigger } from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
const shortcut = { key: "i" };
const validateRunId = makeFriendlyIdValidator("run", "Run");
export function LogsRunIdFilter() {
const { value } = useSearchParams();
@@ -68,14 +70,7 @@ function RunIdDropdown({
setOpen(false);
}, [runId, replace, clearSearchValue]);
let error: string | undefined = undefined;
if (runId) {
if (!runId.startsWith("run_")) {
error = "Run IDs start with 'run_'";
} else if (runId.length !== 25 && runId.length !== 29) {
error = "Run IDs are 25 or 29 characters long";
}
}
const error = runId ? validateRunId(runId) : undefined;
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
@@ -24,6 +24,7 @@ import {
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
import { Button } from "../../primitives/Buttons";
import {
allBatchStatuses,
@@ -225,10 +226,7 @@ function PermanentStatusFilter() {
);
}
function validateBatchId(value: string): string | undefined {
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long";
}
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
function BatchIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
@@ -65,6 +65,7 @@ import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
import { Button } from "../../primitives/Buttons";
import { AIFilterInput } from "./AIFilterInput";
import { BulkActionTypeCombo } from "./BulkAction";
@@ -1713,10 +1714,7 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
);
}
function validateRunId(value: string): string | undefined {
if (!value.startsWith("run_")) return "Run IDs start with 'run_'";
if (value.length !== 25 && value.length !== 29) return "Run IDs are 25 or 29 characters long";
}
const validateRunId = makeFriendlyIdValidator("run", "Run");
function RunIdDropdown(
props: Omit<
@@ -1768,10 +1766,7 @@ function AppliedRunIdFilter() {
);
}
function validateBatchId(value: string): string | undefined {
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long";
}
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
function BatchIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
@@ -1819,10 +1814,7 @@ function AppliedBatchIdFilter() {
);
}
function validateScheduleId(value: string): string | undefined {
if (!value.startsWith("sched_")) return "Schedule IDs start with 'sched_'";
if (value.length !== 27) return "Schedule IDs are 27 characters long";
}
const validateScheduleId = makeFriendlyIdValidator("sched", "Schedule");
function ScheduleIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
@@ -1870,6 +1862,8 @@ function AppliedScheduleIdFilter() {
);
}
// Error ids are `error_<16-char sha256 fingerprint>`, not a fixed-length generated
// id, so they intentionally skip makeFriendlyIdValidator (its length check would reject them).
function validateErrorId(value: string): string | undefined {
if (!value.startsWith("error_")) return "Error IDs start with 'error_'";
}
@@ -33,6 +33,7 @@ import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags";
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
import {
appliedSummary,
FilterMenuProvider,
@@ -398,6 +399,8 @@ function PermanentTagsFilter() {
);
}
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
function WaitpointIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
@@ -407,11 +410,7 @@ function WaitpointIdDropdown(
label="Waitpoint ID"
placeholder="waitpoint_"
paramKey="id"
validate={(v) => {
if (!v.startsWith("waitpoint_")) return "Waitpoint IDs start with 'waitpoint_'";
if (v.length !== 35) return "Waitpoint IDs are 35 characters long";
return undefined;
}}
validate={validateWaitpointId}
/>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
BatchId,
generateFriendlyId,
generateKsuidId,
RunId,
} from "@trigger.dev/core/v3/isomorphic";
import { isValidFriendlyId, makeFriendlyIdValidator } from "./friendlyId";
describe("isValidFriendlyId", () => {
it("accepts every id generation the real generators produce", () => {
// nanoid (legacy V1), cuid (run-engine), ksuid (run-ops split)
expect(isValidFriendlyId(generateFriendlyId("run"), "run")).toBe(true);
expect(isValidFriendlyId(RunId.generate().friendlyId, "run")).toBe(true);
expect(isValidFriendlyId(RunId.toFriendlyId(generateKsuidId()), "run")).toBe(true);
expect(isValidFriendlyId(generateFriendlyId("batch"), "batch")).toBe(true);
expect(isValidFriendlyId(BatchId.generate().friendlyId, "batch")).toBe(true);
expect(isValidFriendlyId(BatchId.toFriendlyId(generateKsuidId()), "batch")).toBe(true);
});
it("accepts each valid body length (21 nanoid, 25 cuid, 27 ksuid)", () => {
expect(isValidFriendlyId("run_" + "a".repeat(21), "run")).toBe(true);
expect(isValidFriendlyId("run_" + "a".repeat(25), "run")).toBe(true);
expect(isValidFriendlyId("run_" + "a".repeat(27), "run")).toBe(true);
});
it("accepts mixed-case (uppercase) ksuid bodies", () => {
expect(isValidFriendlyId("run_2ABCdefGHI0123456789jklMN", "run")).toBe(true);
});
it("rejects the wrong prefix", () => {
expect(isValidFriendlyId(RunId.generate().friendlyId, "batch")).toBe(false);
expect(isValidFriendlyId("batch_" + "a".repeat(25), "run")).toBe(false);
});
it("rejects a bare (unprefixed) id", () => {
expect(isValidFriendlyId("a".repeat(25), "run")).toBe(false);
});
it("rejects body lengths that match no generator", () => {
for (const len of [0, 20, 22, 24, 26, 28]) {
expect(isValidFriendlyId("run_" + "a".repeat(len), "run")).toBe(false);
}
});
it("rejects non-base62 characters in the body", () => {
expect(isValidFriendlyId("run_" + "-".repeat(25), "run")).toBe(false);
expect(isValidFriendlyId("run_" + "!".repeat(25), "run")).toBe(false);
// an underscore in the body is not base62
expect(isValidFriendlyId("run_" + "a".repeat(24) + "_", "run")).toBe(false);
});
it("does not treat the prefix separator as optional", () => {
// "runX..." shares the "run" prefix but not the "run_" marker
expect(isValidFriendlyId("run" + "a".repeat(25), "run")).toBe(false);
});
});
describe("makeFriendlyIdValidator", () => {
const validateRunId = makeFriendlyIdValidator("run", "Run");
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
it("returns undefined for a valid id of any generation", () => {
expect(validateRunId(generateFriendlyId("run"))).toBeUndefined();
expect(validateRunId(RunId.generate().friendlyId)).toBeUndefined();
expect(validateRunId(RunId.toFriendlyId(generateKsuidId()))).toBeUndefined();
expect(validateBatchId(BatchId.toFriendlyId(generateKsuidId()))).toBeUndefined();
});
it("reports a wrong prefix distinctly from a wrong shape", () => {
expect(validateRunId("batch_" + "a".repeat(25))).toBe("Run IDs start with 'run_'");
expect(validateRunId("run_" + "a".repeat(20))).toBe("That doesn't look like a valid run ID");
});
it("derives the marker and label per entity", () => {
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
expect(validateWaitpointId("run_" + "a".repeat(25))).toBe(
"Waitpoint IDs start with 'waitpoint_'"
);
expect(validateWaitpointId("waitpoint_" + "a".repeat(20))).toBe(
"That doesn't look like a valid waitpoint ID"
);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { CUID_LENGTH, KSUID_LENGTH } from "@trigger.dev/core/v3/isomorphic";
// The body after `<prefix>_` is a base62 id; three generator lengths remain
// valid in existing data and must all be accepted: 21 (nanoid), 25 (cuid),
// 27 (ksuid). cuid/ksuid come from core so this tracks any future change.
const NANOID_BODY_LENGTH = 21;
const VALID_BODY_LENGTHS: ReadonlySet<number> = new Set([
NANOID_BODY_LENGTH,
CUID_LENGTH,
KSUID_LENGTH,
]);
const BASE62 = /^[0-9A-Za-z]+$/;
export function isValidFriendlyId(value: string, prefix: string): boolean {
const marker = `${prefix}_`;
if (!value.startsWith(marker)) return false;
const body = value.slice(marker.length);
return VALID_BODY_LENGTHS.has(body.length) && BASE62.test(body);
}
export function makeFriendlyIdValidator(prefix: string, label: string) {
const marker = `${prefix}_`;
return (value: string): string | undefined => {
if (!value.startsWith(marker)) return `${label} IDs start with '${marker}'`;
if (!isValidFriendlyId(value, prefix)) {
return `That doesn't look like a valid ${label.toLowerCase()} ID`;
}
return undefined;
};
}
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
"app/v3/services/bulk/**/*.test.ts",
"app/runEngine/concerns/**/*.test.ts",
"app/runEngine/services/**/*.test.ts",
"app/utils/**/*.test.ts",
],
// *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
// *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts