Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/fix-ksuid-id-filters.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 3 additions & 8 deletions apps/webapp/app/components/logs/LogsRunIdFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}>
Expand Down
6 changes: 2 additions & 4 deletions apps/webapp/app/components/runs/v3/BatchFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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">
Expand Down
18 changes: 6 additions & 12 deletions apps/webapp/app/components/runs/v3/RunFilters.tsx
Comment thread
d-cs marked this conversation as resolved.
Comment thread
d-cs marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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_'";
}
Expand Down
9 changes: 4 additions & 5 deletions apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -398,6 +399,8 @@ function PermanentTagsFilter() {
);
}

const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");

function WaitpointIdDropdown(
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
) {
Expand All @@ -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}
/>
);
}
Expand Down
85 changes: 85 additions & 0 deletions apps/webapp/app/utils/friendlyId.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions apps/webapp/app/utils/friendlyId.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions apps/webapp/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down