diff --git a/.server-changes/fix-ksuid-id-filters.md b/.server-changes/fix-ksuid-id-filters.md new file mode 100644 index 0000000000..815abebbd0 --- /dev/null +++ b/.server-changes/fix-ksuid-id-filters.md @@ -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. diff --git a/apps/webapp/app/components/logs/LogsRunIdFilter.tsx b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx index e23c39534a..a2e52a1a0f 100644 --- a/apps/webapp/app/components/logs/LogsRunIdFilter.tsx +++ b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx @@ -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 ( diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index cb7e7c13ad..83766ce2d6 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -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 diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 6917f07b10..79ff2a73c7 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -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 @@ -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 @@ -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_'"; } diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index 5a8c7a8217..a0f6e1a59f 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -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 ) { @@ -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} /> ); } diff --git a/apps/webapp/app/utils/friendlyId.test.ts b/apps/webapp/app/utils/friendlyId.test.ts new file mode 100644 index 0000000000..4b2854f51c --- /dev/null +++ b/apps/webapp/app/utils/friendlyId.test.ts @@ -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" + ); + }); +}); diff --git a/apps/webapp/app/utils/friendlyId.ts b/apps/webapp/app/utils/friendlyId.ts new file mode 100644 index 0000000000..bc26ef19b4 --- /dev/null +++ b/apps/webapp/app/utils/friendlyId.ts @@ -0,0 +1,31 @@ +import { CUID_LENGTH, KSUID_LENGTH } from "@trigger.dev/core/v3/isomorphic"; + +// The body after `_` 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 = 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; + }; +} diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 8e05aec1eb..f9ec17fec0 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -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