Skip to content

Commit 119189f

Browse files
authored
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.
1 parent 962bc48 commit 119189f

8 files changed

Lines changed: 138 additions & 29 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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.

apps/webapp/app/components/logs/LogsRunIdFilter.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { Label } from "~/components/primitives/Label";
99
import { SelectPopover, SelectProvider, SelectTrigger } from "~/components/primitives/Select";
1010
import { useSearchParams } from "~/hooks/useSearchParam";
1111
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
12+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
1213

1314
const shortcut = { key: "i" };
15+
const validateRunId = makeFriendlyIdValidator("run", "Run");
1416

1517
export function LogsRunIdFilter() {
1618
const { value } = useSearchParams();
@@ -68,14 +70,7 @@ function RunIdDropdown({
6870
setOpen(false);
6971
}, [runId, replace, clearSearchValue]);
7072

71-
let error: string | undefined = undefined;
72-
if (runId) {
73-
if (!runId.startsWith("run_")) {
74-
error = "Run IDs start with 'run_'";
75-
} else if (runId.length !== 25 && runId.length !== 29) {
76-
error = "Run IDs are 25 or 29 characters long";
77-
}
78-
}
73+
const error = runId ? validateRunId(runId) : undefined;
7974

8075
return (
8176
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>

apps/webapp/app/components/runs/v3/BatchFilters.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
2525
import { useSearchParams } from "~/hooks/useSearchParam";
2626
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
27+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
2728
import { Button } from "../../primitives/Buttons";
2829
import {
2930
allBatchStatuses,
@@ -225,10 +226,7 @@ function PermanentStatusFilter() {
225226
);
226227
}
227228

228-
function validateBatchId(value: string): string | undefined {
229-
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
230-
if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long";
231-
}
229+
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
232230

233231
function BatchIdDropdown(
234232
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { useShortcutKeys } from "~/hooks/useShortcutKeys";
6565
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
6666
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
6767
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
68+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
6869
import { Button } from "../../primitives/Buttons";
6970
import { AIFilterInput } from "./AIFilterInput";
7071
import { BulkActionTypeCombo } from "./BulkAction";
@@ -1713,10 +1714,7 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
17131714
);
17141715
}
17151716

1716-
function validateRunId(value: string): string | undefined {
1717-
if (!value.startsWith("run_")) return "Run IDs start with 'run_'";
1718-
if (value.length !== 25 && value.length !== 29) return "Run IDs are 25 or 29 characters long";
1719-
}
1717+
const validateRunId = makeFriendlyIdValidator("run", "Run");
17201718

17211719
function RunIdDropdown(
17221720
props: Omit<
@@ -1768,10 +1766,7 @@ function AppliedRunIdFilter() {
17681766
);
17691767
}
17701768

1771-
function validateBatchId(value: string): string | undefined {
1772-
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
1773-
if (value.length !== 27 && value.length !== 31) return "Batch IDs are 27 or 31 characters long";
1774-
}
1769+
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
17751770

17761771
function BatchIdDropdown(
17771772
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
@@ -1819,10 +1814,7 @@ function AppliedBatchIdFilter() {
18191814
);
18201815
}
18211816

1822-
function validateScheduleId(value: string): string | undefined {
1823-
if (!value.startsWith("sched_")) return "Schedule IDs start with 'sched_'";
1824-
if (value.length !== 27) return "Schedule IDs are 27 characters long";
1825-
}
1817+
const validateScheduleId = makeFriendlyIdValidator("sched", "Schedule");
18261818

18271819
function ScheduleIdDropdown(
18281820
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
@@ -1870,6 +1862,8 @@ function AppliedScheduleIdFilter() {
18701862
);
18711863
}
18721864

1865+
// Error ids are `error_<16-char sha256 fingerprint>`, not a fixed-length generated
1866+
// id, so they intentionally skip makeFriendlyIdValidator (its length check would reject them).
18731867
function validateErrorId(value: string): string | undefined {
18741868
if (!value.startsWith("error_")) return "Error IDs start with 'error_'";
18751869
}

apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { useProject } from "~/hooks/useProject";
3333
import { useSearchParams } from "~/hooks/useSearchParam";
3434
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
3535
import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags";
36+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
3637
import {
3738
appliedSummary,
3839
FilterMenuProvider,
@@ -398,6 +399,8 @@ function PermanentTagsFilter() {
398399
);
399400
}
400401

402+
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
403+
401404
function WaitpointIdDropdown(
402405
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
403406
) {
@@ -407,11 +410,7 @@ function WaitpointIdDropdown(
407410
label="Waitpoint ID"
408411
placeholder="waitpoint_"
409412
paramKey="id"
410-
validate={(v) => {
411-
if (!v.startsWith("waitpoint_")) return "Waitpoint IDs start with 'waitpoint_'";
412-
if (v.length !== 35) return "Waitpoint IDs are 35 characters long";
413-
return undefined;
414-
}}
413+
validate={validateWaitpointId}
415414
/>
416415
);
417416
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
BatchId,
4+
generateFriendlyId,
5+
generateKsuidId,
6+
RunId,
7+
} from "@trigger.dev/core/v3/isomorphic";
8+
import { isValidFriendlyId, makeFriendlyIdValidator } from "./friendlyId";
9+
10+
describe("isValidFriendlyId", () => {
11+
it("accepts every id generation the real generators produce", () => {
12+
// nanoid (legacy V1), cuid (run-engine), ksuid (run-ops split)
13+
expect(isValidFriendlyId(generateFriendlyId("run"), "run")).toBe(true);
14+
expect(isValidFriendlyId(RunId.generate().friendlyId, "run")).toBe(true);
15+
expect(isValidFriendlyId(RunId.toFriendlyId(generateKsuidId()), "run")).toBe(true);
16+
17+
expect(isValidFriendlyId(generateFriendlyId("batch"), "batch")).toBe(true);
18+
expect(isValidFriendlyId(BatchId.generate().friendlyId, "batch")).toBe(true);
19+
expect(isValidFriendlyId(BatchId.toFriendlyId(generateKsuidId()), "batch")).toBe(true);
20+
});
21+
22+
it("accepts each valid body length (21 nanoid, 25 cuid, 27 ksuid)", () => {
23+
expect(isValidFriendlyId("run_" + "a".repeat(21), "run")).toBe(true);
24+
expect(isValidFriendlyId("run_" + "a".repeat(25), "run")).toBe(true);
25+
expect(isValidFriendlyId("run_" + "a".repeat(27), "run")).toBe(true);
26+
});
27+
28+
it("accepts mixed-case (uppercase) ksuid bodies", () => {
29+
expect(isValidFriendlyId("run_2ABCdefGHI0123456789jklMN", "run")).toBe(true);
30+
});
31+
32+
it("rejects the wrong prefix", () => {
33+
expect(isValidFriendlyId(RunId.generate().friendlyId, "batch")).toBe(false);
34+
expect(isValidFriendlyId("batch_" + "a".repeat(25), "run")).toBe(false);
35+
});
36+
37+
it("rejects a bare (unprefixed) id", () => {
38+
expect(isValidFriendlyId("a".repeat(25), "run")).toBe(false);
39+
});
40+
41+
it("rejects body lengths that match no generator", () => {
42+
for (const len of [0, 20, 22, 24, 26, 28]) {
43+
expect(isValidFriendlyId("run_" + "a".repeat(len), "run")).toBe(false);
44+
}
45+
});
46+
47+
it("rejects non-base62 characters in the body", () => {
48+
expect(isValidFriendlyId("run_" + "-".repeat(25), "run")).toBe(false);
49+
expect(isValidFriendlyId("run_" + "!".repeat(25), "run")).toBe(false);
50+
// an underscore in the body is not base62
51+
expect(isValidFriendlyId("run_" + "a".repeat(24) + "_", "run")).toBe(false);
52+
});
53+
54+
it("does not treat the prefix separator as optional", () => {
55+
// "runX..." shares the "run" prefix but not the "run_" marker
56+
expect(isValidFriendlyId("run" + "a".repeat(25), "run")).toBe(false);
57+
});
58+
});
59+
60+
describe("makeFriendlyIdValidator", () => {
61+
const validateRunId = makeFriendlyIdValidator("run", "Run");
62+
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
63+
64+
it("returns undefined for a valid id of any generation", () => {
65+
expect(validateRunId(generateFriendlyId("run"))).toBeUndefined();
66+
expect(validateRunId(RunId.generate().friendlyId)).toBeUndefined();
67+
expect(validateRunId(RunId.toFriendlyId(generateKsuidId()))).toBeUndefined();
68+
expect(validateBatchId(BatchId.toFriendlyId(generateKsuidId()))).toBeUndefined();
69+
});
70+
71+
it("reports a wrong prefix distinctly from a wrong shape", () => {
72+
expect(validateRunId("batch_" + "a".repeat(25))).toBe("Run IDs start with 'run_'");
73+
expect(validateRunId("run_" + "a".repeat(20))).toBe("That doesn't look like a valid run ID");
74+
});
75+
76+
it("derives the marker and label per entity", () => {
77+
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
78+
expect(validateWaitpointId("run_" + "a".repeat(25))).toBe(
79+
"Waitpoint IDs start with 'waitpoint_'"
80+
);
81+
expect(validateWaitpointId("waitpoint_" + "a".repeat(20))).toBe(
82+
"That doesn't look like a valid waitpoint ID"
83+
);
84+
});
85+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { CUID_LENGTH, KSUID_LENGTH } from "@trigger.dev/core/v3/isomorphic";
2+
3+
// The body after `<prefix>_` is a base62 id; three generator lengths remain
4+
// valid in existing data and must all be accepted: 21 (nanoid), 25 (cuid),
5+
// 27 (ksuid). cuid/ksuid come from core so this tracks any future change.
6+
const NANOID_BODY_LENGTH = 21;
7+
const VALID_BODY_LENGTHS: ReadonlySet<number> = new Set([
8+
NANOID_BODY_LENGTH,
9+
CUID_LENGTH,
10+
KSUID_LENGTH,
11+
]);
12+
13+
const BASE62 = /^[0-9A-Za-z]+$/;
14+
15+
export function isValidFriendlyId(value: string, prefix: string): boolean {
16+
const marker = `${prefix}_`;
17+
if (!value.startsWith(marker)) return false;
18+
const body = value.slice(marker.length);
19+
return VALID_BODY_LENGTHS.has(body.length) && BASE62.test(body);
20+
}
21+
22+
export function makeFriendlyIdValidator(prefix: string, label: string) {
23+
const marker = `${prefix}_`;
24+
return (value: string): string | undefined => {
25+
if (!value.startsWith(marker)) return `${label} IDs start with '${marker}'`;
26+
if (!isValidFriendlyId(value, prefix)) {
27+
return `That doesn't look like a valid ${label.toLowerCase()} ID`;
28+
}
29+
return undefined;
30+
};
31+
}

apps/webapp/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default defineConfig({
1616
"app/v3/services/bulk/**/*.test.ts",
1717
"app/runEngine/concerns/**/*.test.ts",
1818
"app/runEngine/services/**/*.test.ts",
19+
"app/utils/**/*.test.ts",
1920
],
2021
// *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
2122
// *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts

0 commit comments

Comments
 (0)