Merge branch 'main' into docs-ai-agents-chat-agent-guide

This commit is contained in:
DKP
2026-08-07 13:43:29 +01:00
committed by GitHub
9 changed files with 636 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Short links like /_/apikeys now take you straight to that page in your current project and environment, so you no longer need the full URL with your org, project and environment in it.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Fixed a rare error where triggering a task could fail if the idempotency key or debounce key contained an invalid null character. The character is now removed automatically and the run is created as normal.
+55
View File
@@ -0,0 +1,55 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { getUsersInvites } from "~/models/member.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import { deeplinkSuffix, resolveDeeplinkPage } from "~/utils/deeplinkPages";
import {
invitesPath,
newOrganizationPath,
newProjectPath,
v3EnvironmentPath,
} from "~/utils/pathBuilder";
//`[_]` escapes the underscore: an unescaped `_.$` is a pathless layout, mounted at `/*`.
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { pathname, search } = new URL(request.url);
const page = resolveDeeplinkPage(deeplinkSuffix(pathname));
const invites = await getUsersInvites({ email: user.email });
if (invites.length > 0) {
return redirect(invitesPath());
}
const presenter = new SelectBestEnvironmentPresenter();
try {
const { project, organization, environment } = await presenter.call({ user });
const environmentPath = v3EnvironmentPath(organization, project, environment);
const suffix = page ? `/${page}` : "";
return redirect(`${environmentPath}${suffix}${search}`);
} catch (_e) {
const organization = await prisma.organization.findFirst({
where: {
members: {
some: {
userId: user.id,
},
},
deletedAt: null,
},
orderBy: {
createdAt: "desc",
},
});
if (organization) {
return redirect(newProjectPath(organization));
}
return redirect(newOrganizationPath());
}
};
@@ -0,0 +1,110 @@
import { describe, expect, vi } from "vitest";
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
runOpsNewPrisma: {},
runOpsLegacyPrisma: {},
runOpsNewReplica: {},
runOpsLegacyReplica: {},
}));
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getEntitlement: vi.fn(),
};
});
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import { assertNonNullable, containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
import { RunEngineTriggerTaskService } from "./triggerTask.server";
import {
buildEngine,
CapturingParentRunValidator,
MockPayloadProcessor,
MockTraceEventConcern,
} from "./triggerTask.server.test.helpers";
vi.setConfig({ testTimeout: 60_000 });
const NUL = String.fromCharCode(0);
function buildService(engine: any, prisma: any) {
return new RunEngineTriggerTaskService({
engine,
prisma,
payloadProcessor: new MockPayloadProcessor(),
queueConcern: new DefaultQueueManager(prisma, engine),
idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, new MockTraceEventConcern()),
validator: new CapturingParentRunValidator(),
traceEventConcern: new MockTraceEventConcern(),
tracer: trace.getTracer("test", "0.0.0"),
metadataMaximumSize: 1024 * 1024 * 1,
});
}
describe("RunEngineTriggerTaskService null-byte sanitization", () => {
containerTest(
"strips a NUL from idempotencyKeyOptions.key so the jsonb insert does not 22P05",
async ({ prisma, redisOptions }) => {
const engine = buildEngine(prisma, redisOptions);
try {
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const service = buildService(engine, prisma);
const result = await service.call({
taskId: "nul-idem-task",
environment,
body: {
payload: { kind: "idem" },
options: {
idempotencyKey: "a".repeat(64),
idempotencyKeyOptions: { key: `acme${NUL}inc`, scope: "run" },
},
},
});
assertNonNullable(result);
const row = await prisma.taskRun.findUniqueOrThrow({ where: { id: result.run.id } });
expect(row.idempotencyKeyOptions).toEqual({ key: "acmeinc", scope: "run" });
} finally {
await engine.quit();
}
}
);
containerTest(
"strips a NUL from debounce.key so the jsonb insert does not 22P05",
async ({ prisma, redisOptions }) => {
const engine = buildEngine(prisma, redisOptions);
try {
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const service = buildService(engine, prisma);
const result = await service.call({
taskId: "nul-debounce-task",
environment,
body: {
payload: { kind: "debounce" },
options: {
debounce: { key: `grp${NUL}1`, delay: "1s" },
},
},
});
assertNonNullable(result);
const row = await prisma.taskRun.findUniqueOrThrow({ where: { id: result.run.id } });
expect((row.debounce as { key: string }).key).toBe("grp1");
} finally {
await engine.quit();
}
}
);
});
@@ -25,6 +25,7 @@ import type { PrismaClientOrTransaction } from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { parseDelay } from "~/utils/delays";
import { removeNullBytesFromKey } from "~/utils/nullBytes";
import { handleMetadataPacket } from "~/utils/packets";
import { startSpan } from "~/v3/tracing.server";
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
@@ -906,7 +907,7 @@ export class RunEngineTriggerTaskService {
environment: args.environment,
idempotencyKey: args.idempotencyKey,
idempotencyKeyExpiresAt: args.idempotencyKey ? args.idempotencyKeyExpiresAt : undefined,
idempotencyKeyOptions: args.body.options?.idempotencyKeyOptions,
idempotencyKeyOptions: removeNullBytesFromKey(args.body.options?.idempotencyKeyOptions),
taskIdentifier: args.taskId,
payload: args.payloadPacket.data ?? "",
payloadType: args.payloadPacket.dataType,
@@ -971,7 +972,7 @@ export class RunEngineTriggerTaskService {
planType: args.planType,
realtimeStreamsVersion: args.options.realtimeStreamsVersion,
streamBasinName: args.environment.organization.streamBasinName,
debounce: args.body.options?.debounce,
debounce: removeNullBytesFromKey(args.body.options?.debounce),
annotations: args.annotations,
};
}
+317
View File
@@ -0,0 +1,317 @@
import { flatRoutes } from "@remix-run/dev/dist/config/flat-routes.js";
import type { RouteManifest } from "@remix-run/dev/dist/config/routes.js";
import { matchPath } from "@remix-run/router";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
DEEPLINK_PATH_PREFIX,
deeplinkSuffix,
ENV_PAGE_TARGETS,
resolveDeeplinkPage,
} from "./deeplinkPages";
const APP_DIR = join(__dirname, "..");
const ROUTES_DIR = join(APP_DIR, "routes");
// The trailing dot excludes the environment layout route itself, which has no segment of its own.
const ENV_ROUTE_PREFIX = "_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.";
// Route files that name no deeplink: the environment root, and Remix's layout-opt-out spelling.
const NOT_DEEPLINK_NAMES = new Set(["_index", "queues_"]);
const PROBE = "probe_01ABC";
const DEEPLINK_ROUTE_FILE = "routes/[_].$.ts";
const compiledRoutes: RouteManifest = flatRoutes(APP_DIR, ["**/.*"]);
function compiledUrl(id: string): string {
if (!compiledRoutes[id]) throw new Error(`no compiled route with id ${id}`);
const segments: string[] = [];
let route = compiledRoutes[id];
while (route) {
if (route.path) segments.unshift(route.path);
route = route.parentId ? compiledRoutes[route.parentId] : undefined;
}
return `/${segments.join("/")}`;
}
const COMPILED_DEEPLINK_PATH = (() => {
const entry = Object.values(compiledRoutes).find((route) => route.file === DEEPLINK_ROUTE_FILE);
if (!entry) throw new Error(`${DEEPLINK_ROUTE_FILE} is not in the compiled route manifest`);
return compiledUrl(entry.id).replace(/\/\*$/, "");
})();
const routeEntries = readdirSync(ROUTES_DIR);
function isRouteModule(entry: string): boolean {
const path = join(ROUTES_DIR, entry);
if (!statSync(path).isDirectory()) return true;
return existsSync(join(path, "route.tsx")) || existsSync(join(path, "route.ts"));
}
// A trailing `_` only opts out of the parent layout: `queues_.$queueParam` serves `/queues/{id}`.
const envRoutes: string[][] = routeEntries
.filter((entry) => entry.startsWith(ENV_ROUTE_PREFIX) && isRouteModule(entry))
.map((entry) =>
entry
.slice(ENV_ROUTE_PREFIX.length)
.replace(/\.(tsx|ts)$/, "")
.split(".")
)
.map((segments) => (segments.at(-1) === "_index" ? segments.slice(0, -1) : segments))
.map((segments) => segments.map((segment) => segment.replace(/_+$/, "")));
function routeMatches(path: string, { allowParams }: { allowParams: boolean }): boolean {
const wanted = path === "" ? [] : path.split("/");
return envRoutes.some(
(route) =>
route.length === wanted.length &&
route.every((segment, i) => (segment.startsWith("$") ? allowParams : segment === wanted[i]))
);
}
function envRouteSegments(): Set<string> {
const segments = new Set<string>();
for (const entry of routeEntries) {
if (!entry.startsWith(ENV_ROUTE_PREFIX)) continue;
const segment = entry.slice(ENV_ROUTE_PREFIX.length).split(/[./]/)[0];
if (!segment || segment === "ts" || segment === "tsx") continue;
segments.add(segment);
}
return segments;
}
function descendantsOf(prefix: string): string[][] {
const depth = prefix === "" ? 0 : prefix.split("/").length;
return envRoutes
.filter((route) => route.length > depth && route.slice(0, depth).join("/") === prefix)
.map((route) =>
route.slice(depth).map((segment) => (segment.startsWith("$") ? PROBE : segment))
);
}
describe("deeplink targets", () => {
it("read enough routes for the assertions below to mean anything", () => {
expect(envRouteSegments().size).toBeGreaterThan(20);
expect(envRoutes.length).toBeGreaterThan(40);
});
it("every bare name lands on a real page that needs no id", () => {
const broken = [...ENV_PAGE_TARGETS.entries()]
.filter(([name]) => !routeMatches(resolveDeeplinkPage(name) ?? " ", { allowParams: false }))
.map(([name, { landing }]) => `${name} -> ${landing || "(environment root)"}`);
expect(broken).toEqual([]);
});
it("every deep path lands on a real route, prefix graft included", () => {
const broken: string[] = [];
for (const [name, { prefix }] of ENV_PAGE_TARGETS) {
for (const rest of descendantsOf(prefix)) {
const suffix = [name, ...rest].join("/");
const resolved = resolveDeeplinkPage(suffix);
if (!routeMatches(resolved ?? " ", { allowParams: true })) {
broken.push(`${suffix} -> ${resolved}`);
}
}
}
expect(broken).toEqual([]);
});
it("has deep paths worth checking", () => {
expect(descendantsOf("waitpoints/tokens").length).toBeGreaterThan(0);
expect(descendantsOf("tasks").length).toBeGreaterThan(2);
expect(descendantsOf("runs").length).toBeGreaterThan(0);
});
it("every environment page has a deeplink name", () => {
const missing = [...envRouteSegments()]
.filter((segment) => !NOT_DEEPLINK_NAMES.has(segment))
.filter(
(segment) => routeMatches(segment, { allowParams: false }) && !ENV_PAGE_TARGETS.has(segment)
)
.sort();
expect(missing).toEqual([]);
});
it("points a 404ing name elsewhere, and gives a redirect shim no name at all", () => {
for (const segment of ["tasks", "waitpoints", "metrics"]) {
expect(routeMatches(segment, { allowParams: false })).toBe(false);
}
expect(ENV_PAGE_TARGETS.get("tasks")).toEqual({ landing: "", prefix: "tasks" });
expect(ENV_PAGE_TARGETS.get("waitpoints")).toEqual({
landing: "waitpoints/tokens",
prefix: "waitpoints/tokens",
});
expect(ENV_PAGE_TARGETS.has("metrics")).toBe(false);
});
});
describe("resolveDeeplinkPage", () => {
it("maps a bare name to its landing page", () => {
expect(resolveDeeplinkPage("apikeys")).toBe("apikeys");
expect(resolveDeeplinkPage("waitpoints")).toBe("waitpoints/tokens");
expect(resolveDeeplinkPage("tasks")).toBe("");
});
it("grafts deeper segments onto the prefix", () => {
expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123");
expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task");
expect(resolveDeeplinkPage("waitpoints/waitpoint_123")).toBe("waitpoints/tokens/waitpoint_123");
});
it("does not duplicate a prefix the caller already wrote out", () => {
expect(resolveDeeplinkPage("waitpoints/tokens")).toBe("waitpoints/tokens");
expect(resolveDeeplinkPage("waitpoints/tokens/waitpoint_123")).toBe(
"waitpoints/tokens/waitpoint_123"
);
});
it("rejects a name that is not a page", () => {
expect(resolveDeeplinkPage("")).toBeUndefined();
expect(resolveDeeplinkPage("nonsense")).toBeUndefined();
expect(resolveDeeplinkPage("metrics")).toBeUndefined();
});
it("matches the page name whatever its case, and resolves it to the map's spelling", () => {
expect(resolveDeeplinkPage("APIKeys")).toBe("apikeys");
expect(resolveDeeplinkPage("Waitpoints")).toBe("waitpoints/tokens");
expect(resolveDeeplinkPage("TASKS")).toBe("");
expect(resolveDeeplinkPage("Bulk-Actions")).toBe("bulk-actions");
expect(resolveDeeplinkPage("Nonsense")).toBeUndefined();
expect(resolveDeeplinkPage("Metrics")).toBeUndefined();
});
it("leaves the case of everything after the name alone", () => {
expect(resolveDeeplinkPage("runs/run_ABC123")).toBe("runs/run_ABC123");
expect(resolveDeeplinkPage("Runs/run_ABC123")).toBe("runs/run_ABC123");
expect(resolveDeeplinkPage("TASKS/standard/My-Task")).toBe("tasks/standard/My-Task");
expect(resolveDeeplinkPage("Waitpoints/waitpoint_ABC")).toBe("waitpoints/tokens/waitpoint_ABC");
expect(resolveDeeplinkPage("Waitpoints/tokens/waitpoint_ABC")).toBe(
"waitpoints/tokens/waitpoint_ABC"
);
expect(resolveDeeplinkPage("Tasks/standard/Group%2FMy-Task")).toBe(
"tasks/standard/Group%2FMy-Task"
);
});
it("recognises a written-out prefix whatever its case, however many segments it spans", () => {
expect(resolveDeeplinkPage("Waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123");
expect(resolveDeeplinkPage("waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123");
expect(resolveDeeplinkPage("WAITPOINTS/TOKENS/wp_123")).toBe("waitpoints/tokens/wp_123");
expect(resolveDeeplinkPage("Waitpoints/Tokens")).toBe("waitpoints/tokens");
});
it("holds for every multi-segment prefix in the map, not just waitpoints", () => {
const multiSegment = [...ENV_PAGE_TARGETS.values()].filter(({ prefix }) =>
prefix.includes("/")
);
expect(multiSegment.length).toBeGreaterThan(0);
for (const { prefix } of multiSegment) {
const shouted = prefix
.split("/")
.map((segment) => segment.toUpperCase())
.join("/");
expect(resolveDeeplinkPage(`${shouted}/${PROBE}`)).toBe(`${prefix}/${PROBE}`);
expect(resolveDeeplinkPage(shouted)).toBe(prefix);
}
});
it("drops traversal segments, in plain and escaped spellings", () => {
expect(resolveDeeplinkPage("runs/../../../etc/passwd")).toBe("runs/etc/passwd");
expect(resolveDeeplinkPage("../runs")).toBe("runs");
expect(resolveDeeplinkPage("runs//run_1")).toBe("runs/run_1");
expect(resolveDeeplinkPage("runs/%2e%2e/%2E%2E/run_1")).toBe("runs/run_1");
expect(resolveDeeplinkPage("runs/%2e/run_1")).toBe("runs/run_1");
expect(resolveDeeplinkPage("runs/%ZZ/run_1")).toBe("runs/run_1");
});
it("passes encoded segments through without re-encoding them", () => {
expect(resolveDeeplinkPage("tasks/standard/group%2Fmy-task")).toBe(
"tasks/standard/group%2Fmy-task"
);
expect(resolveDeeplinkPage("runs/a%3Fb%23c")).toBe("runs/a%3Fb%23c");
// The slash stays escaped, so this addresses one odd id rather than climbing out.
expect(resolveDeeplinkPage("runs/..%2f..%2fetc")).toBe("runs/..%2f..%2fetc");
});
});
describe("the route Remix compiles from the filename", () => {
it("mounts the deeplink route at /_ and nowhere else", () => {
expect(COMPILED_DEEPLINK_PATH).toBe("/_");
expect(COMPILED_DEEPLINK_PATH).toBe(DEEPLINK_PATH_PREFIX);
});
it("does not mount anything as a site-wide splat", () => {
const siteWide = Object.values(compiledRoutes)
.filter((route) => compiledUrl(route.id) === "/*")
.map((route) => route.file);
expect(siteWide).toEqual([]);
});
it("compiled the manifest it is reading, paths and all", () => {
expect(Object.keys(compiledRoutes).length).toBeGreaterThan(400);
expect(compiledUrl("routes/login.magic")).toBe("/login/magic");
expect(
compiledUrl(
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam"
)
).toBe("/orgs/:organizationSlug/projects/:projectParam/env/:envParam/queues/:queueParam");
});
});
describe("deeplinkSuffix", () => {
it("strips the route's own prefix", () => {
expect(deeplinkSuffix("/_/tasks")).toBe("tasks");
expect(deeplinkSuffix("/_/runs/run_123")).toBe("runs/run_123");
});
it("keeps an escaped slash intact, unlike the decoded splat param", () => {
expect(deeplinkSuffix("/_/tasks/standard/group%2Fmy-task")).toBe(
"tasks/standard/group%2Fmy-task"
);
});
it("strips only the prefix, leaving the remainder's case alone", () => {
expect(deeplinkSuffix("/_/runs/run_ABC123")).toBe("runs/run_ABC123");
expect(deeplinkSuffix("/_/tasks/standard/My-Task")).toBe("tasks/standard/My-Task");
});
it("matches the URL the router serves for it, splat case and all", () => {
const route = `${COMPILED_DEEPLINK_PATH}/*`;
expect(matchPath(route, "/_/apikeys")?.params["*"]).toBe("apikeys");
expect(matchPath(route, "/_/runs/run_123")?.params["*"]).toBe("runs/run_123");
expect(matchPath(route, "/_/APIKeys")?.params["*"]).toBe("APIKeys");
expect(matchPath(route, "/deeplink/apikeys")).toBeNull();
expect(matchPath(route, "/apikeys")).toBeNull();
});
it("treats a bare prefix, a trailing slash and anything outside it as no suffix", () => {
expect(deeplinkSuffix("/_")).toBe("");
expect(deeplinkSuffix("/_/")).toBe("");
expect(deeplinkSuffix("/etc")).toBe("");
expect(deeplinkSuffix("/_app/orgs")).toBe("");
});
it("matches what the URL parser actually produces, keeping %2F and resolving %2e%2e", () => {
const encodedSlash = new URL("http://x/_/tasks/standard/group%2Fmy-task");
expect(deeplinkSuffix(encodedSlash.pathname)).toBe("tasks/standard/group%2Fmy-task");
expect(resolveDeeplinkPage(deeplinkSuffix(encodedSlash.pathname))).toBe(
"tasks/standard/group%2Fmy-task"
);
const traversal = new URL("http://x/_/runs/%2e%2e/%2e%2e/etc");
expect(traversal.pathname).toBe("/etc");
expect(resolveDeeplinkPage(deeplinkSuffix(traversal.pathname))).toBeUndefined();
});
});
+77
View File
@@ -0,0 +1,77 @@
export type DeeplinkTarget = {
landing: string;
prefix: string;
};
function page(name: string): DeeplinkTarget {
return { landing: name, prefix: name };
}
export const ENV_PAGE_TARGETS: ReadonlyMap<string, DeeplinkTarget> = new Map([
["agents", page("agents")],
["alerts", page("alerts")],
["apikeys", page("apikeys")],
["batches", page("batches")],
["branches", page("branches")],
["bulk-actions", page("bulk-actions")],
["concurrency", page("concurrency")],
["dashboards", page("dashboards")],
["deployments", page("deployments")],
["dev-branches", page("dev-branches")],
["environment-variables", page("environment-variables")],
["errors", page("errors")],
["limits", page("limits")],
["logs", page("logs")],
["models", page("models")],
["playground", page("playground")],
["prompts", page("prompts")],
["query", page("query")],
["queues", page("queues")],
["regions", page("regions")],
["runs", page("runs")],
["schedules", page("schedules")],
["sessions", page("sessions")],
["settings", page("settings")],
["tasks", { landing: "", prefix: "tasks" }],
["test", page("test")],
["waitpoints", { landing: "waitpoints/tokens", prefix: "waitpoints/tokens" }],
]);
export const DEEPLINK_PATH_PREFIX = "/_";
export function deeplinkSuffix(pathname: string): string {
const withSlash = `${DEEPLINK_PATH_PREFIX}/`;
if (!pathname.startsWith(withSlash)) return "";
return pathname.slice(withSlash.length);
}
//`.` and `..`, plain or escaped as `%2e%2e`, would climb out of the environment path.
function isSafeSegment(segment: string): boolean {
if (segment.length === 0 || segment === "." || segment === "..") return false;
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return false;
}
return decoded !== "." && decoded !== "..";
}
export function resolveDeeplinkPage(suffix: string): string | undefined {
const segments = suffix.split("/").filter(isSafeSegment);
const [first = "", ...rest] = segments;
const target = ENV_PAGE_TARGETS.get(first.toLowerCase());
if (target === undefined) return undefined;
if (rest.length === 0) return target.landing;
const prefixDepth = target.prefix.split("/").length;
const writesPrefix = segments.slice(0, prefixDepth).join("/").toLowerCase() === target.prefix;
const beyondPrefix = writesPrefix ? segments.slice(prefixDepth) : rest;
return [target.prefix, ...beyondPrefix].join("/");
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { removeNullBytes, removeNullBytesFromKey } from "./nullBytes";
describe("removeNullBytes", () => {
it("strips every NUL from a string", () => {
expect(removeNullBytes(`a\u0000b\u0000c`)).toBe("abc");
});
it("returns the same reference when there is no NUL", () => {
const clean = "acme-inc";
expect(removeNullBytes(clean)).toBe(clean);
});
it("passes through undefined and null", () => {
expect(removeNullBytes(undefined)).toBeUndefined();
expect(removeNullBytes(null)).toBeNull();
});
});
describe("removeNullBytesFromKey", () => {
it("strips a NUL from the key while preserving other fields", () => {
expect(removeNullBytesFromKey({ key: `k\u00001`, scope: "run" })).toEqual({
key: "k1",
scope: "run",
});
});
it("returns the same object reference when the key is clean", () => {
const opts = { key: "clean", scope: "run" };
expect(removeNullBytesFromKey(opts)).toBe(opts);
});
it("passes through undefined", () => {
expect(removeNullBytesFromKey(undefined)).toBeUndefined();
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Removes Unicode NUL (U+0000) from a string. Postgres cannot store a NUL in a
* `text` column (SQLSTATE 22021) and rejects a `\u0000` escape when a JSON value
* is stored as `jsonb` (SQLSTATE 22P05), so a caller-supplied NUL reaching
* `taskRun.create()` fails the insert. The `indexOf` guard keeps the common
* (NUL-free) case allocation-free on the trigger hot path.
*/
export function removeNullBytes<T extends string | undefined | null>(value: T): T {
if (typeof value !== "string" || value.indexOf("\u0000") === -1) {
return value;
}
return value.replace(/\u0000/g, "") as T;
}
/**
* Returns `value` with a NUL-stripped `key`, reusing the original object when no
* NUL is present. Used for the user-supplied idempotency-key and debounce
* options, whose `key` lands in a `jsonb` column on the TaskRun row.
*/
export function removeNullBytesFromKey<T extends { key: string } | undefined>(value: T): T {
if (!value) {
return value;
}
const cleaned = removeNullBytes(value.key);
return cleaned === value.key ? value : { ...value, key: cleaned };
}