chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { cors } from "remix-utils/cors";
type CorsMethod = "GET" | "HEAD" | "PUT" | "PATCH" | "POST" | "DELETE";
type CorsOptions = {
methods?: CorsMethod[];
/** Defaults to 5 mins */
maxAge?: number;
origin?: boolean | string;
credentials?: boolean;
exposedHeaders?: string[];
};
export async function apiCors(
request: Request,
response: Response,
options: CorsOptions = { maxAge: 5 * 60 }
): Promise<Response> {
if (hasCorsHeaders(response)) {
return response;
}
return cors(request, response, options);
}
export function makeApiCors(
request: Request,
options: CorsOptions = { maxAge: 5 * 60 }
): (response: Response) => Promise<Response> {
return (response: Response) => apiCors(request, response, options);
}
function hasCorsHeaders(response: Response) {
return response.headers.has("access-control-allow-origin");
}
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod";
const baseBoolEnv = z.preprocess((val) => {
if (typeof val !== "string") {
return val;
}
return ["true", "1"].includes(val.toLowerCase().trim());
}, z.boolean());
// Create a type-safe version that only accepts boolean defaults
export const BoolEnv = baseBoolEnv as Omit<typeof baseBoolEnv, "default"> & {
default: (value: boolean) => z.ZodDefault<typeof baseBoolEnv>;
};
@@ -0,0 +1,71 @@
import { type Prisma, type RuntimeEnvironmentType } from "@trigger.dev/database";
type BranchableEnvironmentInput = {
type: RuntimeEnvironmentType;
parentEnvironmentId: string | null;
isBranchableEnvironment: boolean;
};
export type BranchableEnvironmentType = Extract<RuntimeEnvironmentType, "PREVIEW" | "DEVELOPMENT">;
/**
* The wire/form token for a branchable environment kind, as sent by the CLI and
* dashboard forms.
*/
export type BranchableEnvironmentToken = "preview" | "development";
export function toBranchableEnvironmentType(
env: BranchableEnvironmentToken
): BranchableEnvironmentType {
switch (env) {
case "preview":
return "PREVIEW";
case "development":
return "DEVELOPMENT";
}
}
/**
* Prisma `where` fragment matching the *root* environment of a type — the
* branchable parent, never a branch (branches always carry a `parentEnvironmentId`).
* DEVELOPMENT roots are per-org-member, so pass `userId` to disambiguate between
* members' dev environments.
*
* Use this instead of locating roots by their magic slug (`"dev"` / `"preview"`),
* which is an instance identifier, not a reliable type discriminator. Whether the
* matched root is actually branchable is a separate concern — gate it with
* {@link isBranchableEnvironment} after the lookup.
*/
export function rootEnvironmentWhere(
type: RuntimeEnvironmentType,
opts?: { userId?: string }
): Prisma.RuntimeEnvironmentWhereInput {
return {
type,
parentEnvironmentId: null,
...(type === "DEVELOPMENT" && opts?.userId ? { orgMember: { userId: opts.userId } } : {}),
};
}
/**
* Whether an environment is a branchable parent (i.e. branches can be created
* under it), as opposed to a branch itself or a non-branchable environment.
*
* Branchability is split by type:
* - A branch (any env with a `parentEnvironmentId`) is never itself branchable.
* - DEVELOPMENT roots are always branchable — it's derivable from the structure,
* so we don't trust the `isBranchableEnvironment` column for dev.
* - PREVIEW roots use the `isBranchableEnvironment` column, which is the
* long-standing source of truth (and may hold legacy non-branchable rows).
* - STAGING / PRODUCTION are never branchable.
*
* The `parentEnvironmentId === null` guard is load-bearing: dev *branches* are
* also `type === "DEVELOPMENT"`, so checking the type alone would misclassify
* them. Always go through this helper rather than inlining the rule.
*/
export function isBranchableEnvironment(env: BranchableEnvironmentInput): boolean {
if (env.parentEnvironmentId !== null) return false;
if (env.type === "DEVELOPMENT") return true;
if (env.type === "PREVIEW") return env.isBranchableEnvironment;
return false;
}
+24
View File
@@ -0,0 +1,24 @@
import { GitMeta } from "@trigger.dev/core/v3";
import { z } from "zod";
/** Search/filter params for the branches list pages. */
export const BranchesOptions = z.object({
search: z.string().optional(),
showArchived: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(),
page: z.preprocess((val) => Number(val), z.number()).optional(),
});
/** Payload accepted by the create-branch service/action. */
export const CreateBranchOptions = z.object({
projectId: z.string(),
env: z.enum(["preview", "development"]),
branchName: z.string().min(1),
git: GitMeta.optional(),
});
/** The create-branch form schema (payload + the form's failure redirect path). */
export const CreateBranchFormSchema = CreateBranchOptions.and(
z.object({
failurePath: z.string(),
})
);
+15
View File
@@ -0,0 +1,15 @@
import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";
const customTwMerge = extendTailwindMerge({
extend: {
classGroups: {
// Custom font sizes defined in tailwind.css (--text-xxs, --text-2sm)
"font-size": [{ text: ["xxs", "2sm"] }],
},
},
});
export function cn(...inputs: ClassValue[]) {
return customTwMerge(clsx(inputs));
}
+83
View File
@@ -0,0 +1,83 @@
import type { ColumnFormatType } from "@internal/clickhouse";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
/**
* Format a number as binary bytes (KiB, MiB, GiB, TiB)
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
const i = Math.min(
Math.max(0, Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024))),
units.length - 1
);
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
}
/**
* Format a number as decimal bytes (KB, MB, GB, TB)
*/
export function formatDecimalBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(
Math.max(0, Math.floor(Math.log(Math.abs(bytes)) / Math.log(1000))),
units.length - 1
);
return `${(bytes / Math.pow(1000, i)).toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
}
/**
* Format a large number with human-readable suffix (K, M, B)
*/
export function formatQuantity(value: number): string {
const abs = Math.abs(value);
if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`;
if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
if (abs >= 1_000) return `${(value / 1_000).toFixed(2)}K`;
return value.toLocaleString();
}
/**
* Format a dollar amount with adaptive precision — avoids trailing zeros.
*/
function formatCostAdaptive(dollars: number): string {
if (dollars === 0) return "$0";
const abs = Math.abs(dollars);
if (abs >= 1000) return `$${dollars.toFixed(2)}`;
if (abs >= 1) return `$${dollars.toFixed(2)}`;
if (abs >= 0.01) return `$${dollars.toFixed(4)}`;
if (abs >= 0.0001) return `$${dollars.toFixed(6)}`;
return formatCurrencyAccurate(dollars);
}
/**
* Creates a value formatter function for a given column format type.
* Used by chart tooltips, legend values, and big number cards.
*/
export function createValueFormatter(
format?: ColumnFormatType
): ((value: number) => string) | undefined {
if (!format) return undefined;
switch (format) {
case "bytes":
return (v) => formatBytes(v);
case "decimalBytes":
return (v) => formatDecimalBytes(v);
case "percent":
return (v) => `${v.toFixed(2)}%`;
case "quantity":
return (v) => formatQuantity(v);
case "duration":
return (v) => formatDurationMilliseconds(v, { style: "short" });
case "durationSeconds":
return (v) => formatDurationMilliseconds(v * 1000, { style: "short" });
case "costInDollars":
return (v) => formatCostAdaptive(v);
case "cost":
return (v) => formatCostAdaptive(v / 100);
default:
return undefined;
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Deterministic 0-99 bucket for an org id, stable across processes and deploys.
* FNV-1a (non-crypto): we only need determinism + uniform spread, not collision
* resistance. Used for nested percentage rollout: `hashBucket(orgId) < percentage`.
* Ramping the percentage down keeps a strict subset (the low buckets), so an org
* never flaps in and out as the dial moves.
*/
export function hashBucket(orgId: string): number {
let hash = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < orgId.length; i++) {
hash ^= orgId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash % 100;
}
+82
View File
@@ -0,0 +1,82 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
/**
* Escape a value for CSV format.
* - Wraps in quotes if the value contains commas, quotes, or newlines
* - Escapes quotes by doubling them
*/
function escapeCSVValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
const stringValue = typeof value === "object" ? JSON.stringify(value) : String(value);
// Check if we need to quote the value
if (
stringValue.includes(",") ||
stringValue.includes('"') ||
stringValue.includes("\n") ||
stringValue.includes("\r")
) {
// Escape quotes by doubling them and wrap in quotes
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
/**
* Convert query result rows to CSV format.
*
* @param rows - Array of row objects from query results
* @param columns - Column metadata describing the result columns
* @returns CSV string with header row and data rows
*/
export function rowsToCSV(
rows: Record<string, unknown>[],
columns: OutputColumnMetadata[]
): string {
if (columns.length === 0) {
return "";
}
const columnNames = columns.map((col) => col.name);
// Header row
const headerRow = columnNames.map(escapeCSVValue).join(",");
// Data rows
const dataRows = rows.map((row) =>
columnNames.map((name) => escapeCSVValue(row[name])).join(",")
);
return [headerRow, ...dataRows].join("\n");
}
/**
* Convert query result rows to JSON format.
*
* @param rows - Array of row objects from query results
* @returns Formatted JSON string
*/
export function rowsToJSON(rows: Record<string, unknown>[]): string {
return JSON.stringify(rows, null, 2);
}
/**
* Trigger a file download in the browser.
*
* @param content - The file content as a string
* @param filename - The name for the downloaded file
* @param mimeType - The MIME type of the file
*/
export function downloadFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+18
View File
@@ -0,0 +1,18 @@
export function isValidDatabaseUrl(url: string) {
try {
const databaseUrl = new URL(url);
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
if (schemaFromSearchParam === "") {
console.error(
"Invalid Database URL: The schema search param can't have an empty value. To use the `public` schema, either omit the schema param entirely or specify it in full: `?schema=public`"
);
return false;
}
return true;
} catch (err) {
console.error(err);
return false;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic";
export const calculateDurationInMs = (options: {
seconds?: number;
minutes?: number;
hours?: number;
days?: number;
}) => {
return (
(options?.seconds ?? 0) * 1000 +
(options?.minutes ?? 0) * 60 * 1000 +
(options?.hours ?? 0) * 60 * 60 * 1000 +
(options?.days ?? 0) * 24 * 60 * 60 * 1000
);
};
export async function parseDelay(value?: string | Date): Promise<Date | undefined> {
if (!value) {
return;
}
if (value instanceof Date) {
return value;
}
try {
const date = new Date(value);
// Check if the date is valid
if (isNaN(date.getTime())) {
return parseNaturalLanguageDuration(value);
}
if (date.getTime() <= Date.now()) {
return;
}
return date;
} catch (_error) {
return parseNaturalLanguageDuration(value);
}
}
@@ -0,0 +1,82 @@
/**
* Detects unpaired UTF-16 surrogate escape sequences in JSON-encoded text.
*
* Returns true if the input contains a `\uD8XX`/`\uD9XX`/`\uDAXX`/`\uDBXX`
* high-surrogate escape not immediately followed by a `\uDC..``\uDF..` low
* surrogate, or a `\uDC..``\uDF..` low surrogate not immediately preceded by
* a high surrogate. Strict JSON parsers (e.g. ClickHouse `JSONEachRow`)
* reject input containing such sequences.
*
* Surrogate hex ranges (case-insensitive — inputs from `JSON.stringify` are
* lowercase):
* - High surrogate (U+D800U+DBFF): `\uD[8-B][0-9A-F][0-9A-F]`
* - Low surrogate (U+DC00U+DFFF): `\uD[C-F][0-9A-F][0-9A-F]`
*/
export function detectBadJsonStrings(jsonString: string): boolean {
// Fast path: skip everything if no \u
let idx = jsonString.indexOf("\\u");
if (idx === -1) return false;
// Use a more efficient scanning strategy
const length = jsonString.length;
while (idx !== -1 && idx < length - 5) {
// Only check if we have enough characters left
if (idx + 6 > length) break;
if (jsonString[idx + 1] === "u" && jsonString[idx + 2] === "d") {
const third = jsonString[idx + 3];
// High surrogate check — third nibble is 8, 9, a, or b (U+D800U+DBFF)
if (
/[89ab]/.test(third) &&
/[0-9a-f]/.test(jsonString[idx + 4]) &&
/[0-9a-f]/.test(jsonString[idx + 5])
) {
// Check for low surrogate after (need at least 6 more chars)
if (idx + 12 > length) {
return true; // Incomplete high surrogate (not enough chars left)
}
if (
jsonString[idx + 6] !== "\\" ||
jsonString[idx + 7] !== "u" ||
jsonString[idx + 8] !== "d" ||
!/[c-f]/.test(jsonString[idx + 9]) ||
!/[0-9a-f]/.test(jsonString[idx + 10]) ||
!/[0-9a-f]/.test(jsonString[idx + 11])
) {
return true; // Incomplete high surrogate
}
}
// Low surrogate check — third nibble is c, d, e, or f (U+DC00U+DFFF)
if (
/[c-f]/.test(third) &&
/[0-9a-f]/.test(jsonString[idx + 4]) &&
/[0-9a-f]/.test(jsonString[idx + 5])
) {
// Check for high surrogate before (need at least 6 chars before)
if (idx < 6) {
return true; // Incomplete low surrogate (not enough chars before)
}
if (
jsonString[idx - 6] !== "\\" ||
jsonString[idx - 5] !== "u" ||
jsonString[idx - 4] !== "d" ||
!/[89ab]/.test(jsonString[idx - 3]) ||
!/[0-9a-f]/.test(jsonString[idx - 2]) ||
!/[0-9a-f]/.test(jsonString[idx - 1])
) {
return true; // Incomplete low surrogate
}
}
}
// More efficient next search - skip ahead by 2 to avoid overlapping matches
idx = jsonString.indexOf("\\u", idx + 2);
}
return false;
}
+14
View File
@@ -0,0 +1,14 @@
import { env } from "~/env.server";
import { emailMatchesPattern } from "./emailPattern";
export function assertEmailAllowed(email: string) {
if (!env.WHITELISTED_EMAILS) {
return;
}
if (!emailMatchesPattern(env.WHITELISTED_EMAILS, email)) {
// Surfaced verbatim on the login page. Name the actual policy so a
// rejection on a restricted instance reads as configuration, not a bug.
throw new Error("This email address isn't allowed to sign in on this instance.");
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Match an email against an operator-supplied pattern (ADMIN_EMAILS /
* WHITELISTED_EMAILS), anchored to the whole address with `^(?:...)$` so the
* pattern matches the entire email rather than a substring.
*
* The non-capturing group keeps top-level alternation working
* (`a@x.com|b@x.com` stays two whole-string alternatives). Patterns that
* already carry their own `^`/`$` anchors remain equivalent. A top-level
* alternative that is just `@domain.tld` is expanded to "any mailbox at exactly
* that domain".
*
* Dependency-free so it can be tested directly; callers pass the pattern from `env`.
*/
export function emailMatchesPattern(pattern: string, email: string): boolean {
return new RegExp(`^(?:${expandDomainShorthand(pattern)})$`).test(email);
}
function expandDomainShorthand(pattern: string): string {
return splitTopLevelAlternatives(pattern)
.map((alternative) => {
const domain = alternative.match(/^@([A-Za-z0-9.-]+)$/)?.[1];
return domain ? `[^@]+@${escapeRegExp(domain)}` : alternative;
})
.join("|");
}
function splitTopLevelAlternatives(pattern: string): string[] {
const alternatives: string[] = [];
let current = "";
let escaped = false;
let depth = 0;
let inCharacterClass = false;
for (const char of pattern) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
current += char;
escaped = true;
continue;
}
if (char === "[" && !inCharacterClass) {
inCharacterClass = true;
} else if (char === "]" && inCharacterClass) {
inCharacterClass = false;
} else if (!inCharacterClass && char === "(") {
depth++;
} else if (!inCharacterClass && char === ")" && depth > 0) {
depth--;
}
if (char === "|" && depth === 0 && !inCharacterClass) {
alternatives.push(current);
current = "";
continue;
}
current += char;
}
alternatives.push(current);
return alternatives;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -0,0 +1,2 @@
/** Matches Prisma `EnvironmentPauseSource.BILLING_LIMIT`. Safe for client and server bundles. */
export const ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT = "BILLING_LIMIT" as const;
+87
View File
@@ -0,0 +1,87 @@
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
const environmentSortOrder: RuntimeEnvironmentType[] = [
"DEVELOPMENT",
"STAGING",
"PREVIEW",
"PRODUCTION",
];
type SortType = {
type: RuntimeEnvironmentType;
userName?: string | null;
lastActivity?: Date | undefined;
updatedAt?: Date | undefined;
};
export function sortEnvironments<T extends SortType>(
environments: T[],
sortOrder?: RuntimeEnvironmentType[]
): T[] {
const order = sortOrder ?? environmentSortOrder;
return environments.sort((a, b) => {
const aIndex = order.indexOf(a.type);
const bIndex = order.indexOf(b.type);
const difference = aIndex - bIndex;
if (difference === 0) {
if (a.type === "DEVELOPMENT" && b.type === "DEVELOPMENT") {
// Within the same env type, order by recency: most-recent dev activity
// first, falling back to updatedAt when there's no recorded activity,
// then to username when we have no timestamps at all.
const aTime = (a.lastActivity ?? a.updatedAt)?.getTime();
const bTime = (b.lastActivity ?? b.updatedAt)?.getTime();
if (aTime !== undefined && bTime !== undefined) {
return bTime - aTime;
}
if (aTime !== undefined) return -1;
if (bTime !== undefined) return 1;
}
const usernameA = a.userName || "";
const usernameB = b.userName || "";
return usernameA.localeCompare(usernameB);
}
return difference;
});
}
type FilterableEnvironment =
| {
type: RuntimeEnvironmentType;
orgMemberId?: string;
}
| {
type: RuntimeEnvironmentType;
//intentionally vague so we can match anything
orgMember?: Record<string, any>;
};
export function filterOrphanedEnvironments<T extends FilterableEnvironment>(
environments: T[]
): T[] {
return environments.filter((environment) => {
if (environment.type !== "DEVELOPMENT") return true;
if ("orgMemberId" in environment) {
return !!environment.orgMemberId;
}
if ("orgMember" in environment) {
return !!environment.orgMember;
}
return false;
});
}
export function onlyDevEnvironments<T extends FilterableEnvironment>(environments: T[]): T[] {
return environments.filter((e) => e.type === "DEVELOPMENT");
}
export function exceptDevEnvironments<T extends FilterableEnvironment>(environments: T[]): T[] {
return environments.filter((e) => e.type !== "DEVELOPMENT");
}
@@ -0,0 +1,97 @@
import { createHash } from "node:crypto";
/**
* Calculate error fingerprint using Sentry-style normalization.
* Groups similar errors together by normalizing dynamic values.
*/
export function calculateErrorFingerprint(error: unknown): string {
if (!error || typeof error !== "object" || Array.isArray(error)) return "";
// This is a but ugly but…
// 1. We can't use a schema here because it's a hot path and needs to be fast.
// 2. It won't be an instanceof Error because it's from the database.
const errorObj = error as any;
const errorType = String(errorObj.type || errorObj.name || "Error");
// Fall back to the error class name, then the raw serialized value, so
// messageless errors (e.g. tagged errors) and non-Error throws (strings,
// plain objects) still group by something distinctive instead of collapsing
// into a single fingerprint. Message-bearing errors are unaffected.
const message = String(errorObj.message || errorObj.name || errorObj.raw || "");
const stack = String(errorObj.stack || errorObj.stacktrace || "");
// Normalize message to group similar errors
const normalizedMessage = normalizeErrorMessage(message);
// Extract and normalize first few stack frames
const normalizedStack = normalizeStackTrace(stack);
// Create fingerprint from type + normalized message + stack
const fingerprintInput = `${errorType}:${normalizedMessage}:${normalizedStack}`;
// Use SHA-256 hash, take first 16 chars for compact storage
return createHash("sha256").update(fingerprintInput).digest("hex").substring(0, 16);
}
/**
* Normalize error message by replacing dynamic values with placeholders.
* This allows similar errors to be grouped together.
*/
export function normalizeErrorMessage(message: string): string {
if (!message) return "";
return (
message
// UUIDs (8-4-4-4-12 format)
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
// Run IDs (run_xxxxx format)
.replace(/run_[a-zA-Z0-9]+/g, "<run-id>")
// Task run friendly IDs (task_xxxxx or similar)
.replace(/\b[a-z]+_[a-zA-Z0-9]{8,}\b/g, "<id>")
// --- Specific patterns must run before generic numeric/path replacements ---
// ISO 8601 timestamps
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, "<timestamp>")
// Unix timestamps (10 or 13 digits)
.replace(/\b\d{10,13}\b/g, "<timestamp>")
// URLs (before path regex, which would strip the URL's path component)
.replace(/https?:\/\/[^\s]+/g, "<url>")
// --- Generic replacements ---
// Standalone numeric IDs (4+ digits)
.replace(/\b\d{4,}\b/g, "<id>")
// File paths (Unix style)
.replace(/(?:\/[^/\s]+){2,}/g, "<path>")
// File paths (Windows style)
.replace(/[A-Z]:\\(?:[^\\]+\\)+[^\\]+/g, "<path>")
// Email addresses
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "<email>")
// Memory addresses (0x...)
.replace(/0x[0-9a-fA-F]{8,}/g, "<addr>")
// Quoted strings with dynamic content
.replace(/"[^"]{20,}"/g, '"<string>"')
.replace(/'[^']{20,}'/g, "'<string>'")
);
}
/**
* Normalize stack trace by taking first few frames and removing dynamic parts.
*/
export function normalizeStackTrace(stack: string): string {
if (!stack) return "";
// Take first 5 stack frames only
const lines = stack.split("\n").slice(0, 5);
return lines
.map((line) => {
// Remove line and column numbers (file.ts:123:45 -> file.ts:_:_)
line = line.replace(/:\d+:\d+/g, ":_:_");
// Remove standalone numbers
line = line.replace(/\b\d+\b/g, "_");
// Remove file paths but keep filename
line = line.replace(/(?:\/[^/\s]+)+\/([^/\s]+)/g, "$1");
// Normalize whitespace
line = line.trim();
return line;
})
.filter((line) => line.length > 0)
.join("|");
}
@@ -0,0 +1,10 @@
/**
* Extracts the client IP address from the X-Forwarded-For header.
* Takes the last item in the header since ALB appends the real client IP by default.
*/
export function extractClientIp(xff: string | null): string | null {
if (!xff) return null;
const parts = xff.split(",").map((p) => p.trim());
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
}
+13
View File
@@ -0,0 +1,13 @@
export function extractDomain(input: string): string | null {
try {
const withProtocol = input.includes("://") ? input : `https://${input}`;
const url = new URL(withProtocol);
return url.hostname;
} catch {
return null;
}
}
export function faviconUrl(domain: string, size: number = 128): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
BatchId,
generateFriendlyId,
generateRunOpsId,
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), run-ops v1 (run-ops split)
expect(isValidFriendlyId(generateFriendlyId("run"), "run")).toBe(true);
expect(isValidFriendlyId(RunId.generate().friendlyId, "run")).toBe(true);
expect(isValidFriendlyId(RunId.toFriendlyId(generateRunOpsId()), "run")).toBe(true);
expect(isValidFriendlyId(generateFriendlyId("batch"), "batch")).toBe(true);
expect(isValidFriendlyId(BatchId.generate().friendlyId, "batch")).toBe(true);
expect(isValidFriendlyId(BatchId.toFriendlyId(generateRunOpsId()), "batch")).toBe(true);
});
it("accepts each valid body length (21 nanoid, 25 cuid, 26 run-ops v1, 27 legacy base62)", () => {
expect(isValidFriendlyId("run_" + "a".repeat(21), "run")).toBe(true);
expect(isValidFriendlyId("run_" + "a".repeat(25), "run")).toBe(true);
expect(isValidFriendlyId("run_" + "a".repeat(26), "run")).toBe(true);
expect(isValidFriendlyId("run_" + "a".repeat(27), "run")).toBe(true);
});
it("accepts mixed-case (uppercase) legacy base62 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, 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(generateRunOpsId()))).toBeUndefined();
expect(validateBatchId(BatchId.toFriendlyId(generateRunOpsId()))).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"
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { CUID_LENGTH, RUN_OPS_ID_LENGTH } from "@trigger.dev/core/v3/isomorphic";
// The body after `<prefix>_` is an alphanumeric id; four generator lengths
// remain valid in existing data and must all be accepted: 21 (nanoid),
// 25 (cuid), 26 (run-ops v1 base32hex), 27 (pre-cutover base62, kept so old
// ids still pass filter validation). cuid/run-ops come from core so this
// tracks any future change.
const NANOID_BODY_LENGTH = 21;
const LEGACY_BASE62_BODY_LENGTH = 27;
const VALID_BODY_LENGTHS: ReadonlySet<number> = new Set([
NANOID_BODY_LENGTH,
CUID_LENGTH,
RUN_OPS_ID_LENGTH,
LEGACY_BASE62_BODY_LENGTH,
]);
const ALPHANUMERIC = /^[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) && ALPHANUMERIC.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;
};
}
+48
View File
@@ -0,0 +1,48 @@
type Options<R> = {
startDate: Date;
endDate: Date;
window?: "MINUTE" | "HOUR" | "DAY";
data: { date: Date; value?: R }[];
};
export function createTimeSeriesData<R>({ startDate, endDate, window = "DAY", data }: Options<R>) {
const outputData: Array<{ date: Date; value?: R }> = [];
const periodLength = periodLengthMs(window);
const periodCount = Math.round((endDate.getTime() - startDate.getTime()) / periodLength);
for (let i = 0; i < periodCount; i++) {
const periodStart = new Date(startDate);
periodStart.setTime(periodStart.getTime() + i * periodLength);
const periodEnd = new Date(startDate);
periodEnd.setTime(periodEnd.getTime() + (i + 1) * periodLength);
const foundData = data.find((d) => {
const time = d.date.getTime();
const inRange = time >= periodStart.getTime() && time < periodEnd.getTime();
return inRange;
});
if (!foundData) {
outputData.push({
date: periodStart,
});
} else {
outputData.push({
date: periodStart,
value: foundData.value,
});
}
}
return outputData;
}
function periodLengthMs(window: "MINUTE" | "HOUR" | "DAY") {
switch (window) {
case "MINUTE":
return 60_000;
case "HOUR":
return 3_600_000;
case "DAY":
return 86_400_000;
}
}
+5
View File
@@ -0,0 +1,5 @@
import type { ExternalScriptsFunction } from "remix-utils/external-scripts";
export type Handle = {
scripts?: ExternalScriptsFunction;
};
+43
View File
@@ -0,0 +1,43 @@
export function throwNotFound(statusText: string): never {
throw new Response(undefined, { status: 404, statusText });
}
export function friendlyErrorDisplay(statusCode: number, statusText?: string) {
switch (statusCode) {
case 400:
return {
title: "400: Bad request",
message: statusText ?? "The request was invalid.",
};
case 401:
return {
title: "401: Unauthorized",
message: statusText ?? "Please sign in to continue.",
};
case 403:
return {
title: "403: Forbidden",
message: statusText ?? "You don't have permission to access this resource.",
};
case 404:
return {
title: "404: Page not found",
message: statusText ?? "The page you're looking for doesn't exist.",
};
case 429:
return {
title: "429: Too many requests",
message: statusText ?? "Please wait a moment and try again.",
};
case 500:
return {
title: "500: Server error",
message: statusText ?? "Something went wrong on our end. Please try again later.",
};
default:
return {
title: `${statusCode}: Error`,
message: statusText ?? "An error occurred.",
};
}
}
@@ -0,0 +1,41 @@
/**
* Resolve the TTL for an idempotency key.
*
* The TTL format is a string like "5m", "1h", "7d"
*
* @param ttl The TTL string
* @returns The date when the key will expire
* @throws If the TTL string is invalid
*/
export function resolveIdempotencyKeyTTL(ttl: string | undefined | null): Date | undefined {
if (!ttl) {
return undefined;
}
const match = ttl.match(/^(\d+)([smhd])$/);
if (!match) {
return;
}
const [, value, unit] = match;
const now = new Date();
switch (unit) {
case "s":
now.setSeconds(now.getSeconds() + parseInt(value, 10));
break;
case "m":
now.setMinutes(now.getMinutes() + parseInt(value, 10));
break;
case "h":
now.setHours(now.getHours() + parseInt(value, 10));
break;
case "d":
now.setDate(now.getDate() + parseInt(value, 10));
break;
}
return now;
}
+33
View File
@@ -0,0 +1,33 @@
// An inviter can only assign a role at or below their own. The systemRoles
// array is in canonical order (highest authority first), so array index drives
// the ladder. Custom roles aren't in the table and are refused. Dependency-free
// so the rule can be unit-tested directly.
export type LadderRole = { id: string };
export function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
const level: Record<string, number> = {};
roles.forEach((r, i) => {
// Top of the array = highest level; larger number means more authority.
level[r.id] = roles.length - i;
});
return level;
}
/**
* Whether an inviter holding `inviterRoleId` may assign `invitedRoleId`.
* A roleless inviter (`inviterRoleId == null`) and custom/unknown roles absent
* from the ladder are all refused.
*/
export function isAtOrBelow(
roles: ReadonlyArray<LadderRole>,
inviterRoleId: string | null,
invitedRoleId: string
): boolean {
if (!inviterRoleId) return false;
const level = buildRoleLevel(roles);
const inviter = level[inviterRoleId];
const invited = level[invitedRoleId];
if (inviter === undefined || invited === undefined) return false;
return invited <= inviter;
}
+66
View File
@@ -0,0 +1,66 @@
import type { z } from "zod";
export function safeJsonParse(json?: string): unknown {
if (!json) {
return;
}
try {
return JSON.parse(json);
} catch (_e) {
return null;
}
}
export function safeJsonZodParse<T>(
schema: z.Schema<T>,
json: string
): z.SafeParseReturnType<unknown, T> | undefined {
const parsed = safeJsonParse(json);
if (parsed === null) {
return;
}
return schema.safeParse(parsed);
}
export async function safeJsonFromResponse(response: Response) {
const json = await response.text();
return safeJsonParse(json);
}
export async function safeBodyFromResponse<T>(
response: Response,
schema: z.Schema<T>
): Promise<T | undefined> {
const json = await response.text();
const unknownJson = safeJsonParse(json);
if (!unknownJson) {
return;
}
const parsedJson = schema.safeParse(unknownJson);
if (parsedJson.success) {
return parsedJson.data;
}
}
export async function safeParseBodyFromResponse<T>(
response: Response,
schema: z.Schema<T>
): Promise<z.SafeParseReturnType<unknown, T> | undefined> {
try {
const unknownJson = await response.json();
if (!unknownJson) {
return;
}
const parsedJson = schema.safeParse(unknownJson);
return parsedJson;
} catch (_error) {}
}
+15
View File
@@ -0,0 +1,15 @@
/** Linearly interpolates between the min/max values, using t.
* It can't go outside the range */
export function lerp(min: number, max: number, t: number) {
return min + (max - min) * clamp(t, 0, 1);
}
/** Inverse lerp */
export function inverseLerp(min: number, max: number, value: number) {
return (value - min) / (max - min);
}
/** Clamps a value between a min and max */
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+112
View File
@@ -0,0 +1,112 @@
import { createElement, type ReactNode } from "react";
import { z } from "zod";
export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
export type LogLevel = z.infer<typeof LogLevelSchema>;
export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
// Default styles for search highlighting
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
backgroundColor: "#facc15", // yellow-400
color: "#000000",
fontWeight: "500",
borderRadius: "0.25rem",
padding: "0 0.125rem",
} as const;
/**
* Highlights all occurrences of a search term in text with consistent styling.
* Case-insensitive search with regex special character escaping.
*
* @param text - The text to search within
* @param searchTerm - The term to highlight (optional)
* @param style - Optional custom inline styles for highlights
* @returns React nodes with highlighted matches, or the original text if no matches
*/
export function highlightSearchText(
text: string,
searchTerm?: string,
style: React.CSSProperties = DEFAULT_HIGHLIGHT_STYLES
): ReactNode {
if (!searchTerm || searchTerm.trim() === "") {
return text;
}
// Defense in depth: limit search term length to prevent ReDoS and performance issues
const MAX_SEARCH_LENGTH = 500;
if (searchTerm.length > MAX_SEARCH_LENGTH) {
return text;
}
// Escape special regex characters in search term
const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedSearch, "gi");
const parts: ReactNode[] = [];
let lastIndex = 0;
let match;
let matchCount = 0;
while ((match = regex.exec(text)) !== null) {
// Add text before match
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
// Add highlighted match
parts.push(createElement("span", { key: `match-${matchCount}`, style }, match[0]));
lastIndex = regex.lastIndex;
matchCount++;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts.length > 0 ? parts : text;
}
// Convert ClickHouse kind to display level
export function kindToLevel(kind: string, status: string): LogLevel {
// ERROR can come from either kind or status
if (kind === "LOG_ERROR" || status === "ERROR") {
return "ERROR";
}
switch (kind) {
case "DEBUG_EVENT":
case "LOG_DEBUG":
return "DEBUG";
case "LOG_INFO":
return "INFO";
case "LOG_WARN":
return "WARN";
case "LOG_LOG":
return "INFO"; // Changed from "LOG"
case "SPAN":
return "TRACE";
case "ANCESTOR_OVERRIDE":
case "SPAN_EVENT":
default:
return "INFO";
}
}
// Level badge color styles
export function getLevelColor(level: LogLevel): string {
switch (level) {
case "ERROR":
return "text-error bg-error/10 border-error/20";
case "WARN":
return "text-warning bg-warning/10 border-warning/20";
case "TRACE":
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
case "DEBUG":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
default:
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
}
}
+75
View File
@@ -0,0 +1,75 @@
// When proxying long-polling requests, content-encoding & content-length are added
// erroneously (saying the body is gzipped when it's not) so we'll just remove
// them to avoid content decoding errors in the browser.
//
import { logger } from "~/services/logger.server";
// Similar-ish problem to https://github.com/wintercg/fetch/issues/23
export async function longPollingFetch(
url: string,
options?: RequestInit,
rewriteResponseHeaders?: Record<string, string>
) {
let upstream: Response | undefined;
try {
upstream = await fetch(url, options);
let response = upstream;
if (response.headers.get("content-encoding")) {
const headers = new Headers(response.headers);
headers.delete("content-encoding");
headers.delete("content-length");
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
if (rewriteResponseHeaders) {
const headers = new Headers(response.headers);
for (const [fromKey, toKey] of Object.entries(rewriteResponseHeaders)) {
const value = headers.get(fromKey);
if (value) {
headers.set(toKey, value);
headers.delete(fromKey);
}
}
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
return response;
} catch (error) {
// Release upstream undici socket + buffers explicitly. Without this the
// ReadableStream stays open and undici keeps buffering chunks into memory
// until the upstream times out (see H1 isolation test — ~44 KB retained
// per unconsumed-body fetch in RSS).
try {
await upstream?.body?.cancel();
} catch {}
// AbortError is the expected path when downstream disconnects with a
// propagated signal — treat as a clean client-close, not a server error.
if (error instanceof Error && error.name === "AbortError") {
throw new Response(null, { status: 499 });
}
if (error instanceof TypeError) {
logger.error("Network error:", { error: error.message });
throw new Response("Network error occurred", { status: 503 });
} else if (error instanceof Error) {
logger.error("Fetch error:", { error: error.message });
throw new Response(error.message, { status: 500 });
} else {
logger.error("Unknown error occurred during fetch");
throw new Response("An unknown error occurred", { status: 500 });
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import { formatNumberCompact } from "./numberFormatter";
/** Format a per-token price as $/1M tokens. */
export function formatModelPrice(pricePerToken: number | null): string {
if (pricePerToken === null) return "—";
const perMillion = pricePerToken * 1_000_000;
if (perMillion < 0.01) return `$${perMillion.toFixed(4)}`;
if (perMillion < 1) return `$${perMillion.toFixed(3)}`;
return `$${perMillion.toFixed(2)}`;
}
/** Format a token count (context window, max output). */
export function formatTokenCount(tokens: number | null): string {
if (tokens === null) return "—";
return formatNumberCompact(tokens);
}
/** Format a dollar cost value. */
export function formatModelCost(dollars: number): string {
if (dollars === 0) return "$0";
if (dollars < 0.01) return `$${dollars.toFixed(4)}`;
if (dollars < 1) return `$${dollars.toFixed(3)}`;
return `$${dollars.toFixed(2)}`;
}
/** Format a feature slug (snake_case) to Title Case. */
export function formatFeature(slug: string): string {
return slug
.toLowerCase()
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
/** @deprecated Use formatFeature instead. */
export const formatCapability = formatFeature;
/** Capitalize a provider name. */
export function formatProviderName(provider: string): string {
const names: Record<string, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
google: "Google",
meta: "Meta",
mistral: "Mistral",
cohere: "Cohere",
ai21: "AI21",
amazon: "Amazon",
xai: "xAI",
deepseek: "DeepSeek",
qwen: "Qwen",
perplexity: "Perplexity",
nous: "Nous",
};
return names[provider.toLowerCase()] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
}
+50
View File
@@ -0,0 +1,50 @@
const compactFormatter = Intl.NumberFormat("en", { notation: "compact", compactDisplay: "short" });
export const formatNumberCompact = (num: number): string => {
return compactFormatter.format(num);
};
const formatter = Intl.NumberFormat("en");
// Formatter for small decimal values that need more precision
const preciseFormatter = Intl.NumberFormat("en", {
minimumSignificantDigits: 1,
maximumSignificantDigits: 6,
});
export const formatNumber = (num: number): string => {
// For very small numbers (between -1 and 1, exclusive), use precise formatting
// to avoid rounding 0.000025 to 0
if (num !== 0 && Math.abs(num) < 1) {
return preciseFormatter.format(num);
}
return formatter.format(num);
};
const roundedCurrencyFormatter = Intl.NumberFormat("en-US", {
style: "currency",
currencyDisplay: "symbol",
maximumFractionDigits: 0,
currency: "USD",
});
const currencyFormatter = Intl.NumberFormat("en-US", {
style: "currency",
currencyDisplay: "symbol",
currency: "USD",
});
export const formatCurrency = (num: number, rounded: boolean): string => {
return rounded ? roundedCurrencyFormatter.format(num) : currencyFormatter.format(num);
};
const accurateCurrencyFormatter = Intl.NumberFormat("en-US", {
style: "currency",
currencyDisplay: "symbol",
minimumFractionDigits: 8,
maximumFractionDigits: 8,
currency: "USD",
});
export function formatCurrencyAccurate(num: number): string {
return accurateCurrencyFormatter.format(num);
}
+14
View File
@@ -0,0 +1,14 @@
export function omit<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> {
const result: any = {};
for (const key of Object.keys(obj)) {
if (!keys.includes(key as K)) {
result[key] = obj[key];
}
}
return result;
}
+37
View File
@@ -0,0 +1,37 @@
import type { IOPacket } from "@trigger.dev/core/v3/utils/ioSerialization";
import { ServiceValidationError } from "~/v3/services/common.server";
export class MetadataTooLargeError extends ServiceValidationError {
constructor(message: string) {
super(message, 413);
this.name = "MetadataTooLargeError";
}
}
export function handleMetadataPacket(
metadata: any,
metadataType: string,
maximumSize: number
): IOPacket | undefined {
let metadataPacket: IOPacket | undefined = undefined;
if (typeof metadata === "string") {
metadataPacket = { data: metadata, dataType: metadataType };
}
if (metadataType === "application/json") {
metadataPacket = { data: JSON.stringify(metadata), dataType: "application/json" };
}
if (!metadataPacket || !metadataPacket.data) {
return;
}
const byteLength = Buffer.byteLength(metadataPacket.data, "utf8");
if (byteLength > maximumSize) {
throw new MetadataTooLargeError(`Metadata exceeds maximum size of ${maximumSize} bytes`);
}
return metadataPacket;
}
@@ -0,0 +1,29 @@
import type { Attributes } from "@opentelemetry/api";
import { startActiveSpan } from "~/v3/tracer.server";
export async function parseRequestJsonAsync(
request: Request,
attributes?: Attributes
): Promise<unknown> {
return await startActiveSpan(
"parseRequestJsonAsync()",
async (span) => {
span.setAttribute("content-length", parseInt(request.headers.get("content-length") ?? "0"));
span.setAttribute("content-type", request.headers.get("content-type") ?? "application/json");
span.setAttribute("experiment.async", false);
const rawText = await startActiveSpan("request.text()", async () => {
return await request.text();
});
if (rawText.length === 0) {
return;
}
return JSON.parse(rawText);
},
{
attributes,
}
);
}
+806
View File
@@ -0,0 +1,806 @@
import type { RuntimeEnvironment, TaskRun, WorkerDeployment } from "@trigger.dev/database";
import { z } from "zod";
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import type { Organization } from "~/models/organization.server";
import type { Project } from "~/models/project.server";
import { RUNS_BULK_INSPECTOR_OPEN_VALUE } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
import { objectToSearchParams } from "./searchParams";
import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters";
export type OrgForPath = Pick<Organization, "slug">;
export type ProjectForPath = Pick<Project, "slug">;
export type EnvironmentForPath = Pick<RuntimeEnvironment, "slug">;
export type v3RunForPath = Pick<TaskRun, "friendlyId">;
export type v3SpanForPath = Pick<TaskRun, "spanId">;
export type DeploymentForPath = Pick<WorkerDeployment, "shortCode">;
export type TaskForPath = {
taskIdentifier: string;
};
export const OrganizationParamsSchema = z.object({
organizationSlug: z.string(),
});
export const ProjectParamSchema = OrganizationParamsSchema.extend({
projectParam: z.string(),
});
export const EnvironmentParamSchema = ProjectParamSchema.extend({
envParam: z.string(),
});
//v3
export const v3TaskParamsSchema = EnvironmentParamSchema.extend({
taskParam: z.string(),
});
export const v3RunParamsSchema = EnvironmentParamSchema.extend({
runParam: z.string(),
});
export const v3SpanParamsSchema = v3RunParamsSchema.extend({
spanParam: z.string(),
});
export const v3RunStreamParamsSchema = v3RunParamsSchema.extend({
streamKey: z.string(),
});
export const v3DeploymentParams = EnvironmentParamSchema.extend({
deploymentParam: z.string(),
});
export const v3ScheduleParams = EnvironmentParamSchema.extend({
scheduleParam: z.string(),
});
export function rootPath() {
return `/`;
}
/** Given a path, it makes it an impersonation path */
export function impersonate(path: string) {
return `/@${path}`;
}
export function accountPath() {
return `/account`;
}
export function personalAccessTokensPath() {
return `/account/tokens`;
}
export function accountSecurityPath() {
return `/account/security`;
}
export function invitesPath() {
return `/invites`;
}
export function confirmBasicDetailsPath() {
return `/confirm-basic-details`;
}
export function acceptInvitePath(token: string) {
return `/invite-accept?token=${token}`;
}
export function resendInvitePath() {
return `/invite-resend`;
}
export function logoutPath() {
return `/logout`;
}
export function revokeInvitePath() {
return `/invite-revoke`;
}
// Org
export function organizationPath(organization: OrgForPath) {
return `/orgs/${organizationParam(organization)}`;
}
export function newOrganizationPath() {
return `/orgs/new`;
}
export function selectPlanPath(organization: OrgForPath) {
return `${organizationPath(organization)}/select-plan`;
}
export function organizationTeamPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/team`;
}
export function organizationRolesPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/roles`;
}
export function organizationSsoPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/sso`;
}
export function inviteTeamMemberPath(organization: OrgForPath) {
return `${organizationPath(organization)}/invite`;
}
export function organizationBillingPath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing`;
}
export function organizationSettingsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings`;
}
export function organizationIntegrationsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/integrations`;
}
export function organizationVercelIntegrationPath(organization: OrgForPath) {
return `${organizationIntegrationsPath(organization)}/vercel`;
}
export function organizationSlackIntegrationPath(organization: OrgForPath) {
return `${organizationIntegrationsPath(organization)}/slack`;
}
function organizationParam(organization: OrgForPath) {
return organization.slug;
}
// Project
export function newProjectPath(organization: OrgForPath, message?: string) {
return `${organizationPath(organization)}/projects/new${
message ? `?message=${encodeURIComponent(message)}` : ""
}`;
}
function projectParam(project: ProjectForPath) {
return project.slug;
}
function environmentParam(environment: EnvironmentForPath) {
return environment.slug;
}
//v3 project
export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath) {
return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`;
}
export function githubAppInstallPath(organizationSlug: string, redirectTo: string) {
return `/github/install?org_slug=${organizationSlug}&redirect_to=${encodeURIComponent(
redirectTo
)}`;
}
export function vercelAppInstallPath(organizationSlug: string, projectSlug: string) {
return `/vercel/install?org_slug=${organizationSlug}&project_slug=${projectSlug}`;
}
export function vercelCallbackPath() {
return `/vercel/callback`;
}
export function vercelResourcePath(
organizationSlug: string,
projectSlug: string,
environmentSlug: string
) {
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`;
}
export function v3EnvironmentPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `/orgs/${organizationParam(organization)}/projects/${projectParam(
project
)}/env/${environmentParam(environment)}`;
}
export function v3TasksDashboardPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/tasks/dashboard`;
}
export function v3TasksStreamingPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/tasks/stream`;
}
export function v3ApiKeysPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/apikeys`;
}
export function v3BulkActionsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/bulk-actions`;
}
export function v3BulkActionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
bulkAction: { friendlyId: string }
) {
return `${v3BulkActionsPath(organization, project, environment)}/${bulkAction.friendlyId}`;
}
export function v3EnvironmentVariablesPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/environment-variables`;
}
export function v3NewEnvironmentVariablesPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentVariablesPath(organization, project, environment)}/new`;
}
export function v3ProjectAlertsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/alerts`;
}
export function v3NewProjectAlertPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3ProjectAlertsPath(organization, project, environment)}/new`;
}
export function v3NewProjectAlertPathConnectToSlackPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3ProjectAlertsPath(organization, project, environment)}/new/connect-to-slack`;
}
export function v3TestPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/test`;
}
export function queryPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/query`;
}
export function v3CustomDashboardPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
dashboard: { friendlyId: string }
) {
return `${v3EnvironmentPath(organization, project, environment)}/dashboards/custom/${
dashboard.friendlyId
}`;
}
export function v3BuiltInDashboardPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
key: string
) {
return `${v3EnvironmentPath(organization, project, environment)}/dashboards/${key}`;
}
export function v3DashboardsLandingPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/dashboards`;
}
export function v3TestTaskPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
task: TaskForPath
) {
return `${v3TestPath(organization, project, environment)}/tasks/${encodeURIComponent(
task.taskIdentifier
)}`;
}
export function v3PlaygroundPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/playground`;
}
export function v3PlaygroundAgentPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
agentSlug: string
) {
return `${v3PlaygroundPath(organization, project, environment)}/${encodeURIComponent(agentSlug)}`;
}
export function v3AgentTaskPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
agentSlug: string
) {
return `${v3EnvironmentPath(organization, project, environment)}/agents/${encodeURIComponent(
agentSlug
)}`;
}
export function v3StandardTaskPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
taskSlug: string
) {
return `${v3EnvironmentPath(organization, project, environment)}/tasks/standard/${encodeURIComponent(
taskSlug
)}`;
}
export function v3ScheduledTaskPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
taskSlug: string
) {
return `${v3EnvironmentPath(organization, project, environment)}/tasks/scheduled/${encodeURIComponent(
taskSlug
)}`;
}
export function v3RunsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
filters?: TaskRunListSearchFilters
) {
const searchParams = objectToSearchParams(filters);
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3EnvironmentPath(organization, project, environment)}/runs${query}`;
}
export function v3CreateBulkActionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
filters?: TaskRunListSearchFilters,
mode?: "selected" | "filter",
action?: "replay" | "cancel"
) {
const searchParams = objectToSearchParams(filters) ?? new URLSearchParams();
searchParams.set("bulkInspector", RUNS_BULK_INSPECTOR_OPEN_VALUE);
if (mode) {
searchParams.set("mode", mode);
}
if (action) {
searchParams.set("action", action);
}
const query = `?${searchParams.toString()}`;
return `${v3RunsPath(organization, project, environment)}${query}`;
}
export function v3RunPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath,
searchParams?: URLSearchParams
) {
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`;
}
export function v3RunRedirectPath(
organization: OrgForPath,
project: ProjectForPath,
run: v3RunForPath
) {
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
}
export function v3RunPathFromFriendlyId(runId: string) {
return `/runs/${runId}`;
}
export function v3RunDownloadLogsPath(run: v3RunForPath) {
return `/resources/runs/${run.friendlyId}/logs/download`;
}
export function v3RunSpanPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath,
span: v3SpanForPath,
searchParams?: URLSearchParams
) {
searchParams = searchParams ?? new URLSearchParams();
searchParams.set("span", span.spanId);
return `${v3RunPath(organization, project, environment, run, searchParams)}`;
}
export function v3RunStreamingPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
) {
return `${v3RunPath(organization, project, environment, run)}/stream`;
}
export function v3RunIdempotencyKeyResetPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
) {
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
project
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
}
export function v3SchedulePath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
schedule: { friendlyId: string }
) {
return `${v3EnvironmentPath(organization, project, environment)}/schedules/${
schedule.friendlyId
}`;
}
export function v3EditSchedulePath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
schedule: { friendlyId: string }
) {
return `${v3EnvironmentPath(organization, project, environment)}/schedules/edit/${
schedule.friendlyId
}`;
}
export function v3NewSchedulePath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/schedules/new`;
}
export function v3SchedulesAddOnPath(organization: OrgForPath) {
return `/resources/orgs/${organizationParam(organization)}/schedules-addon`;
}
export function v3QueuesPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/queues`;
}
export function v3WaitpointTokensPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
filters?: WaitpointSearchParams
) {
const searchParams = objectToSearchParams(filters);
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3EnvironmentPath(organization, project, environment)}/waitpoints/tokens${query}`;
}
export function v3WaitpointTokenPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
token: { id: string },
filters?: WaitpointSearchParams
) {
const searchParams = objectToSearchParams(filters);
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3WaitpointTokensPath(organization, project, environment)}/${token.id}${query}`;
}
export function v3BatchesPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/batches`;
}
export function v3SessionsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/sessions`;
}
export function v3SessionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
session: { friendlyId: string }
) {
return `${v3SessionsPath(organization, project, environment)}/${session.friendlyId}`;
}
export function v3BatchPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
batch: { friendlyId: string }
) {
return `${v3BatchesPath(organization, project, environment)}/${batch.friendlyId}`;
}
export function v3BatchRunsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
batch: { friendlyId: string }
) {
return `${v3RunsPath(organization, project, environment, { batchId: batch.friendlyId })}`;
}
export function v3ProjectSettingsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/settings`;
}
export function v3ProjectSettingsGeneralPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3ProjectSettingsPath(organization, project, environment)}/general`;
}
export function v3ProjectSettingsIntegrationsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3ProjectSettingsPath(organization, project, environment)}/integrations`;
}
export function v3LogsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/logs`;
}
export function v3PromptsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/prompts`;
}
export function v3PromptPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
promptSlug: string,
version?: string | number
) {
const base = `${v3PromptsPath(organization, project, environment)}/${promptSlug}`;
return version != null ? `${base}?version=${version}` : base;
}
export function v3ModelsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/models`;
}
export function v3ModelDetailPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
modelId: string
) {
return `${v3ModelsPath(organization, project, environment)}/${modelId}`;
}
export function v3ModelComparePath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3ModelsPath(organization, project, environment)}/compare`;
}
export function v3ErrorsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/errors`;
}
export function v3ErrorsConnectToSlackPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3ErrorsPath(organization, project, environment)}/connect-to-slack`;
}
export function v3ErrorPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
error: { fingerprint: string }
) {
return `${v3ErrorsPath(organization, project, environment)}/${error.fingerprint}`;
}
export function v3DeploymentsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/deployments`;
}
export function v3DeploymentPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
deployment: DeploymentForPath,
currentPage: number
) {
const query = currentPage ? `?page=${currentPage}` : "";
return `${v3DeploymentsPath(organization, project, environment)}/${deployment.shortCode}${query}`;
}
export function v3DeploymentVersionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
version: string
) {
return `${v3DeploymentsPath(organization, project, environment)}?version=${version}`;
}
export function branchesPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/branches`;
}
export function branchesDevPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/dev-branches`;
}
export function concurrencyPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/concurrency`;
}
export function limitsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/limits`;
}
export function regionsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/regions`;
}
export function v3BillingPath(organization: OrgForPath, message?: string) {
return `${organizationPath(organization)}/settings/billing${
message ? `?message=${encodeURIComponent(message)}` : ""
}`;
}
export function v3BillingLimitsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/billing-limits`;
}
/** @deprecated Use v3BillingLimitsPath — redirects from billing-alerts are preserved */
export function v3BillingAlertsPath(organization: OrgForPath) {
return v3BillingLimitsPath(organization);
}
export function v3PrivateConnectionsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/private-connections`;
}
export function v3NewPrivateConnectionPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/private-connections/new`;
}
export function v3StripePortalPath(organization: OrgForPath) {
return `/resources/${organization.slug}/subscription/portal`;
}
export function v3UsagePath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/usage`;
}
// Docs
export function docsRoot() {
return "https://trigger.dev/docs";
}
export function docsPath(path: string) {
return `${docsRoot()}/${path}`;
}
export function docsTroubleshootingPath(path: string) {
return `${docsRoot()}/v3/troubleshooting`;
}
export function adminPath() {
return `/@`;
}
+19
View File
@@ -0,0 +1,19 @@
/** Convert a period string like "7d", "24h", "30m" to milliseconds. Defaults to 7d. */
export function parsePeriodToMs(period: string): number {
const match = period.match(/^(\d+)([mhdw])$/);
if (!match) return 7 * 24 * 60 * 60 * 1000;
const [, numStr, unit] = match;
const num = parseInt(numStr, 10);
switch (unit) {
case "m":
return num * 60 * 1000;
case "h":
return num * 60 * 60 * 1000;
case "d":
return num * 24 * 60 * 60 * 1000;
case "w":
return num * 7 * 24 * 60 * 60 * 1000;
default:
return 7 * 24 * 60 * 60 * 1000;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { json } from "@remix-run/server-runtime";
// Marker on the thrown 403 body so the error boundary can tell a
// permission denial apart from any other route error.
export const PERMISSION_DENIED_MARKER = "rbac-permission-denied";
const DEFAULT_PERMISSION_DENIED_MESSAGE = "You don't have permission to access this page.";
/** Build the 403 response thrown when the current role lacks access. */
export function permissionDeniedResponse(message?: string): Response {
return json(
{ [PERMISSION_DENIED_MARKER]: true, message: message ?? DEFAULT_PERMISSION_DENIED_MESSAGE },
{ status: 403 }
);
}
/**
* Throw from a loader/action when the current role lacks access. The thrown
* 403 bubbles to the nearest route ErrorBoundary, where RouteErrorDisplay
* renders the permission panel. `dashboardLoader`/`dashboardAction` do this
* automatically when an `authorization` block fails; call this directly for
* checks the block can't express (e.g. "any of these permissions").
*/
export function throwPermissionDenied(message?: string): never {
throw permissionDeniedResponse(message);
}
/** Returns the message when `data` is a permission-denied payload, else null. */
export function permissionDeniedMessage(data: unknown): string | null {
if (
data &&
typeof data === "object" &&
(data as Record<string, unknown>)[PERMISSION_DENIED_MARKER]
) {
const message = (data as Record<string, unknown>).message;
return typeof message === "string" ? message : DEFAULT_PERMISSION_DENIED_MESSAGE;
}
return null;
}
+62
View File
@@ -0,0 +1,62 @@
import type { uiComponent } from "@team-plain/typescript-sdk";
import { PlainClient } from "@team-plain/typescript-sdk";
import { env } from "~/env.server";
type Input = {
userId: string;
email: string;
name: string;
title: string;
components: ReturnType<typeof uiComponent.text>[];
labelTypeIds?: string[];
};
export async function sendToPlain({ userId, email, name, title, components, labelTypeIds }: Input) {
if (!env.PLAIN_API_KEY) {
return;
}
const client = new PlainClient({
apiKey: env.PLAIN_API_KEY,
});
const upsertCustomerRes = await client.upsertCustomer({
identifier: {
emailAddress: email,
},
onCreate: {
externalId: userId,
fullName: name,
email: {
email: email,
isVerified: true,
},
},
onUpdate: {
externalId: { value: userId },
fullName: { value: name },
email: {
email: email,
isVerified: true,
},
},
});
if (upsertCustomerRes.error) {
console.error("Failed to upsert customer in Plain", upsertCustomerRes.error);
return;
}
const createThreadRes = await client.createThread({
customerIdentifier: {
customerId: upsertCustomerRes.data.customer.id,
},
title: title,
components: components,
labelTypeIds,
});
if (createThreadRes.error) {
console.error("Failed to create thread in Plain", createThreadRes.error);
}
}
+142
View File
@@ -0,0 +1,142 @@
import { Prisma, type PrismaClient, isPrismaKnownError } from "@trigger.dev/database";
import { logger } from "~/services/logger.server";
// Minimal structural logger so this stays decoupled from the concrete Logger
// (and lets tests pass a capturing logger).
type ErrorLogger = { error: (message: string, fields?: Record<string, unknown>) => void };
// Prisma connectivity / infrastructure error codes — engine- and
// connection-level failures, not query- or validation-level ones. When the
// database is unreachable, Prisma 6.x throws a PrismaClientKnownRequestError
// carrying one of these codes (e.g. P1001 "Can't reach database server").
const INFRASTRUCTURE_PRISMA_CODES = new Set([
"P1001", // Can't reach database server
"P1002", // Database server reached but timed out
"P1008", // Operations timed out
"P1017", // Server has closed the connection
]);
/**
* True when `error` is a Prisma infrastructure/connectivity failure (DB
* unreachable, timed out, connection dropped) rather than a query- or
* validation-level error.
*
* These errors carry internal infrastructure detail (e.g. the database
* hostname) in their `.message`, so they must never be surfaced to API
* clients — callers should let them propagate to the generic 5xx handler
* (which both scrubs the message and is retryable by the SDK) instead of
* folding `.message` into a client-facing error.
*/
export function isInfrastructureError(error: unknown): boolean {
if (
error instanceof Prisma.PrismaClientInitializationError ||
error instanceof Prisma.PrismaClientRustPanicError ||
error instanceof Prisma.PrismaClientUnknownRequestError
) {
return true;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return INFRASTRUCTURE_PRISMA_CODES.has(error.code);
}
return false;
}
// One-shot marker so a single infra error is logged exactly once: the client
// extension (statement level) tags it, and the $transaction-boundary loggers
// skip a tagged error rather than logging the same failure a second time.
const INFRA_ERROR_LOGGED: unique symbol = Symbol("prismaInfraErrorLogged");
function markInfraErrorLogged(error: unknown): void {
if (typeof error !== "object" || error === null) {
return;
}
try {
// Non-enumerable so error-spreads/serializers can't copy the marker onto a
// different error; try/catch so a frozen error object can't make this throw
// and mask the original error as it propagates out of the catch.
Object.defineProperty(error, INFRA_ERROR_LOGGED, {
value: true,
enumerable: false,
configurable: true,
writable: true,
});
} catch {
// best-effort: a sealed/frozen error simply won't be deduped.
}
}
export function infraErrorAlreadyLogged(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
(error as Record<symbol, unknown>)[INFRA_ERROR_LOGGED] === true
);
}
// Logs infrastructure failures (P1xxx-class, see isInfrastructureError) and
// rethrows the ORIGINAL error: callers branch on error.code, and this fires
// per-statement inside transactions, so converting it would break that.
export function captureInfrastructureErrors<T extends PrismaClient>(
client: T,
log: ErrorLogger = logger
): T {
return client.$extends({
name: "infrastructure-error-capture",
query: {
$allOperations: async ({ model, operation, args, query }) => {
try {
return await query(args);
} catch (error) {
if (isInfrastructureError(error)) {
log.error("prisma infrastructure error", {
model,
operation,
code: error instanceof Prisma.PrismaClientKnownRequestError ? error.code : undefined,
meta: error instanceof Prisma.PrismaClientKnownRequestError ? error.meta : undefined,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
markInfraErrorLogged(error);
}
throw error;
}
},
},
}) as unknown as T;
}
// Logs infrastructure errors that reach the $transaction boundary WITHOUT a
// Prisma error code (e.g. PrismaClientInitializationError). Coded errors there
// are already logged by transac()'s callback, and errors that bubbled up from a
// statement were already logged (and tagged) by the client extension — both are
// skipped here to avoid double-logging. Returns whether it logged.
export function logTransactionInfrastructureError(
error: unknown,
log: ErrorLogger = logger
): boolean {
if (
!isInfrastructureError(error) ||
isPrismaKnownError(error) ||
infraErrorAlreadyLogged(error)
) {
return false;
}
log.error("prisma.$transaction infrastructure error", {
message: error instanceof Error ? error.message : String(error),
name: error instanceof Error ? error.name : undefined,
stack: error instanceof Error ? error.stack : undefined,
});
return true;
}
// Replaces a Prisma infrastructure error's message (which carries the DB
// hostname) with a generic one before it reaches an API client. Any other
// error's message is returned unchanged. Status codes/headers are unaffected.
export function clientSafeErrorMessage(error: Error): string {
return isInfrastructureError(error) ? "Internal Server Error" : error.message;
}
@@ -0,0 +1,65 @@
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
export interface QueryPerformanceConfig {
verySlowQueryThreshold?: number; // ms
maxQueryLogLength: number;
}
export class QueryPerformanceMonitor {
private config: QueryPerformanceConfig;
constructor(config: Partial<QueryPerformanceConfig> = {}) {
this.config = {
maxQueryLogLength: 1000,
...config,
};
}
onQuery(
clientType: "writer" | "replica",
log: {
duration: number;
query: string;
params: string;
target: string;
timestamp: Date;
}
) {
if (this.config.verySlowQueryThreshold === undefined) {
return;
}
const { duration, query, params, target, timestamp } = log;
// Slow queries are an observability signal (DB load, missing indexes,
// etc.), not an application error. Logged at warn so it lands in stdout
// without flowing to Sentry — track via metrics/dashboards instead.
if (duration > this.config.verySlowQueryThreshold) {
const truncatedQuery =
query.length > this.config.maxQueryLogLength
? query.substring(0, this.config.maxQueryLogLength) + "..."
: query;
logger.warn("Prisma: very slow database query", {
clientType,
durationMs: duration,
query: truncatedQuery,
target,
timestamp,
paramCount: this.countParams(query),
hasParams: params !== "[]" && params !== "",
});
}
}
private countParams(query: string): number {
// Count the number of $1, $2, etc. parameters in the query
const paramMatches = query.match(/\$\d+/g);
return paramMatches ? paramMatches.length : 0;
}
}
export const queryPerformanceMonitor = new QueryPerformanceMonitor({
verySlowQueryThreshold: env.VERY_SLOW_QUERY_THRESHOLD_MS,
});
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
// Redacts the given object based on the given paths
// Example:
// const redactor = new Redactor(["data.object.balance_transaction"]);
// redactor.redact({
// data: {
// object: {
// balance_transaction: "txn_1NYWgTI0XSgju2urW3aXpinM",
// },
// },
// });
// Returns:
// {
// data: {
// object: {
// balance_transaction: "[REDACTED]",
// },
// },
// }
// Does not currenly support arrays
export class Redactor {
constructor(private paths: string[]) {}
public redact(subject: unknown): unknown {
if (!Array.isArray(this.paths)) {
return subject;
}
if (this.paths.length === 0) {
return subject;
}
const clonedSubject = JSON.parse(JSON.stringify(subject));
return this.redactPathsRecursive(clonedSubject, this.paths);
}
private redactPathsRecursive(subject: any, paths: string[]): any {
for (let path of paths) {
let parts = path.split(".");
let curSubject = subject;
// Make sure curSubject is an object
if (typeof curSubject !== "object") {
break;
}
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (Object.prototype.hasOwnProperty.call(curSubject, part) === false) {
// Path is not found in object
break;
}
if (i === parts.length - 1) {
// We're at the end of our path and have a string, redact it
curSubject[part] = "[REDACTED]";
} else if (part in curSubject && typeof curSubject[part] === "object") {
// More paths to follow, continue down the path
curSubject = curSubject[part];
} else {
// Path is not found in object or doesn't point to a string
break;
}
}
}
return subject;
}
}
+8
View File
@@ -0,0 +1,8 @@
export function isValidRegex(regex: string) {
try {
new RegExp(regex);
return true;
} catch (_err) {
return false;
}
}
@@ -0,0 +1,142 @@
import pRetry from "p-retry";
import { Counter, Gauge } from "prom-client";
import { metricsRegister } from "~/metrics.server";
import { logger } from "~/services/logger.server";
import { signalsEmitter } from "~/services/signals.server";
const loadFailures = new Counter({
name: "reloading_registry_load_failures_total",
help: "Failed loads of a reloading registry",
labelNames: ["name"],
registers: [metricsRegister],
});
const lastSuccessfulLoadAt = new Gauge({
name: "reloading_registry_last_successful_load_timestamp_seconds",
help: "Unix time of the last successful registry load (staleness signal)",
labelNames: ["name"],
registers: [metricsRegister],
});
// 0 until the first successful load, then 1. Starts at 0 (not absent) so a
// never-loaded registry is an alertable series, distinct from "feature off".
const registryLoaded = new Gauge({
name: "reloading_registry_loaded",
help: "1 once the registry has loaded at least once, else 0 (0 = serving cold fallback)",
labelNames: ["name"],
registers: [metricsRegister],
});
export type ReloadingRegistry<T> = {
isReady: Promise<void>;
readonly isLoaded: boolean;
current(): T | undefined;
reload(): Promise<void>;
stop(): void;
};
export type ReloadingRegistryOptions<T> = {
/** Tag for metrics + logs. */
name: string;
/** Loads the full snapshot from the source of truth. */
load: () => Promise<T>;
/** How often to reload after the first successful load. */
intervalMs: number;
/** Startup retry config; defaults to forever with backoff. */
retry?: { retries?: number };
/** Start the background load + interval at construction. Default true; set false to keep inert (e.g. tests). */
autoStart?: boolean;
};
/**
* In-memory snapshot loaded at startup and refreshed on an interval. Reads are
* synchronous (`current()`) and return undefined until the first load completes;
* callers must tolerate that (e.g. fall back to a safe default), the same cold-start
* contract as the datastore / LLM-pricing registries. Interval-only: no pub/sub
* (a follow-up if sub-second propagation is ever needed).
*/
export function createReloadingRegistry<T>(
opts: ReloadingRegistryOptions<T>
): ReloadingRegistry<T> {
let snapshot: T | undefined;
let loaded = false;
let loadSeq = 0;
let resolveReady!: () => void;
const isReady = new Promise<void>((resolve) => {
resolveReady = resolve;
});
let interval: ReturnType<typeof setInterval> | undefined;
function startReloadInterval() {
interval = setInterval(() => {
doLoad().catch((err) => {
loadFailures.inc({ name: opts.name });
logger.warn("[ReloadingRegistry] reload failed", {
name: opts.name,
error: err instanceof Error ? err.message : String(err),
});
});
}, opts.intervalMs);
interval.unref(); // never keep the process alive; SIGTERM still clears it
}
async function doLoad() {
const seq = ++loadSeq;
const next = await opts.load();
if (seq < loadSeq) return; // a newer load started while we were awaiting; don't clobber
snapshot = next;
lastSuccessfulLoadAt.set({ name: opts.name }, Date.now() / 1000);
if (!loaded) {
loaded = true;
registryLoaded.set({ name: opts.name }, 1);
resolveReady();
// Poll only after the first load lands, so the startup retry can't race it.
if (opts.autoStart !== false) startReloadInterval();
}
}
if (opts.autoStart !== false) {
registryLoaded.set({ name: opts.name }, 0); // visible cold series until first load
pRetry(() => doLoad(), {
forever: opts.retry?.retries === undefined,
retries: opts.retry?.retries,
minTimeout: 1_000,
maxTimeout: 60_000,
factor: 2,
onFailedAttempt: (error) => {
loadFailures.inc({ name: opts.name });
logger.warn("[ReloadingRegistry] startup load failed, retrying", {
name: opts.name,
attemptNumber: error.attemptNumber,
retriesLeft: error.retriesLeft,
error: error.message,
});
},
}).catch((err) => {
logger.error("[ReloadingRegistry] startup load gave up", {
name: opts.name,
error: err instanceof Error ? err.message : String(err),
});
});
} else {
resolveReady(); // inert: any direct `await isReady` resolves immediately
}
function stop() {
if (interval) clearInterval(interval);
}
signalsEmitter.on("SIGTERM", stop);
signalsEmitter.on("SIGINT", stop);
return {
isReady,
get isLoaded() {
return loaded;
},
current: () => snapshot,
reload: doLoad,
stop,
};
}
@@ -0,0 +1,96 @@
import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { logger } from "~/services/logger.server";
import { requestIdempotency } from "~/services/requestIdempotencyInstance.server";
import { startActiveSpan } from "~/v3/tracer.server";
type RequestIdempotencyType = "batch-trigger" | "trigger" | "create-batch";
export type IdempotencyConfig<T, R> = {
requestType: RequestIdempotencyType;
findCachedEntity: (cachedRequestId: string) => Promise<T | null>;
buildResponse: (entity: T) => R;
buildResponseHeaders: (response: R, entity: T) => Promise<Record<string, string>>;
};
export async function handleRequestIdempotency<T, R>(
requestIdempotencyKey: string | null | undefined,
config: IdempotencyConfig<T, R>
): Promise<Response | null> {
if (!requestIdempotencyKey) {
return null;
}
logger.debug(`request-idempotency: checking for cached ${config.requestType} request`, {
requestIdempotencyKey,
});
return startActiveSpan("RequestIdempotency.handle()", async (span) => {
span.setAttribute("request_idempotency_key", requestIdempotencyKey);
const cachedRequest = await requestIdempotency.checkRequest(
config.requestType,
requestIdempotencyKey
);
if (!cachedRequest) {
span.setAttribute("cached_request", false);
return null;
}
span.setAttribute("cached_request", true);
span.setAttribute("cached_entity_id", cachedRequest.id);
logger.info(`request-idempotency: found cached ${config.requestType} request`, {
requestIdempotencyKey,
cachedRequest,
});
const cachedEntity = await config.findCachedEntity(cachedRequest.id);
if (!cachedEntity) {
span.setAttribute("cached_entity", false);
return null;
}
span.setAttribute("cached_entity", true);
logger.info(`request-idempotency: found cached ${config.requestType} entity`, {
requestIdempotencyKey,
cachedRequest,
cachedEntity,
});
const responseBody = config.buildResponse(cachedEntity);
const responseHeaders = await config.buildResponseHeaders(responseBody, cachedEntity);
return json(responseBody, { status: 200, headers: responseHeaders });
});
}
export async function saveRequestIdempotency(
requestIdempotencyKey: string | null | undefined,
requestType: RequestIdempotencyType,
entityId: string
): Promise<void> {
if (!requestIdempotencyKey) {
return;
}
const [error] = await tryCatch(
requestIdempotency.saveRequest(requestType, requestIdempotencyKey, {
id: entityId,
})
);
if (error) {
logger.error("request-idempotency: error saving request", {
error,
requestIdempotencyKey,
requestType,
entityId,
});
}
}
@@ -0,0 +1,10 @@
// Updates the protocol of the request url to match the request.headers x-forwarded-proto
export function requestUrl(request: Request): URL {
const url = new URL(request.url);
if (request.headers.get("x-forwarded-proto") === "https") {
url.protocol = "https:";
}
return url;
}
+58
View File
@@ -0,0 +1,58 @@
export type NormalizedRuntime = "node" | "bun";
export interface ParsedRuntime {
/** The normalized runtime type */
runtime: NormalizedRuntime;
/** The original runtime string */
originalRuntime: string;
/** The display name for the runtime */
displayName: string;
}
/**
* Parses a runtime string and returns normalized runtime information
*/
export function parseRuntime(runtime: string | null | undefined): ParsedRuntime | null {
if (!runtime) {
return null;
}
// Normalize runtime strings
let normalizedRuntime: NormalizedRuntime;
let displayName: string;
if (runtime.startsWith("bun")) {
normalizedRuntime = "bun";
displayName = "Bun";
} else if (runtime.startsWith("node")) {
normalizedRuntime = "node";
displayName = "Node.js";
} else {
return null;
}
return {
runtime: normalizedRuntime,
originalRuntime: runtime,
displayName,
};
}
/**
* Formats runtime with version for display
*/
export function formatRuntimeWithVersion(
runtime: string | null | undefined,
version: string | null | undefined
): string {
const parsed = parseRuntime(runtime);
if (!parsed) {
return "Unknown runtime";
}
if (version) {
return `${parsed.displayName} v${version}`;
}
return parsed.displayName;
}
@@ -0,0 +1,21 @@
/**
* Whether `request` is an unambiguously same-origin navigation, used to
* CSRF-gate state-changing GET routes. `allowedOrigin` is the dashboard origin
* (caller passes `env.LOGIN_ORIGIN`, kept out so the rule stays testable).
*
* Deny-by-default: prefer `Sec-Fetch-Site: same-origin` when present, otherwise
* require a `Referer` whose origin matches `allowedOrigin`. Anything
* missing/cross-site/unparseable returns `false`.
*/
export function isSameOriginNavigation(request: Request, allowedOrigin: string): boolean {
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite) return fetchSite === "same-origin";
const referer = request.headers.get("referer");
if (!referer) return false;
try {
return new URL(referer).origin === new URL(allowedOrigin).origin;
} catch {
return false;
}
}
+14
View File
@@ -0,0 +1,14 @@
// Return the URL only if it uses an http(s) scheme, else `undefined` so callers
// can fall back to a default. Use for any URL rendered into an `<a href>`.
const SAFE_HTTP_PROTOCOLS = new Set(["http:", "https:"]);
export function sanitizeHttpUrl(url: string | undefined | null): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url);
return SAFE_HTTP_PROTOCOLS.has(parsed.protocol) ? parsed.href : undefined;
} catch {
return undefined;
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { ZodType } from "zod";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
/**
* Parses a comma-separated `runIds` query param into a trimmed, de-duplicated
* list of run friendly IDs, capped at 100. Shared by the runs `/live` and
* `/children-statuses` resource routes.
*/
export const runIdsQueryParam = z
.string()
.optional()
.transform((value) => {
const ids =
value
?.split(",")
.map((id) => id.trim())
.filter(Boolean) ?? [];
return [...new Set(ids)].slice(0, 100);
});
/**
* `parseInt` accepts garbage-suffixed numbers (`parseInt("123abc", 10) === 123`)
* and returns `NaN` for non-numeric input. Use this helper at loader boundaries
* for URL-supplied integer params so a malformed URL silently falls back to
* `undefined` rather than nudging downstream logic with a partial or NaN value.
*/
export function parseFiniteInt(value: string | null | undefined): number | undefined {
if (value == null || value === "") return undefined;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) ? n : undefined;
}
export function objectToSearchParams(
obj:
| undefined
| Record<string, string | string[] | number | number[] | boolean | boolean[] | undefined>
): URLSearchParams | undefined {
if (!obj) return undefined;
const searchParams = new URLSearchParams();
//for each item add to the search params, skip undefined and join arrays with commas
Object.entries(obj).forEach(([key, value]) => {
if (value === undefined) return;
if (Array.isArray(value)) {
for (const v of value) {
searchParams.append(key, v.toString());
}
} else {
searchParams.append(key, value.toString());
}
});
return searchParams;
}
class SearchParams<TParams extends ParamType> {
constructor(
private params: TParams,
readonly schema: ZodType<TParams>
) {}
get(key: keyof TParams) {
return this.params[key];
}
getAll() {
return this.params;
}
set(key: keyof TParams, value: TParams[keyof TParams]) {
//check it matches the schema
const newParams = { ...this.params, [key]: value };
const result = parseSearchParams(newParams, this.schema);
if (!result.success) {
return { success: false, error: result.error };
}
this.params = newParams;
return { success: true };
}
}
type SearchParamsResult<TParams extends ParamType> =
| { success: true; params: SearchParams<TParams> }
| { success: false; error: string };
type ParamType = Record<string, any>;
export function createSearchParams<TParams extends ParamType>(
url: string,
schema: ZodType<TParams>
): SearchParamsResult<TParams> {
const searchParams = new URL(url).searchParams;
const params = Object.fromEntries(searchParams.entries());
const parsed = parseSearchParams(params, schema);
if (!parsed.success) {
return { success: false, error: parsed.error };
}
return { success: true, params: new SearchParams<TParams>(parsed.params as TParams, schema) };
}
function parseSearchParams<TParams extends ParamType>(params: TParams, schema: ZodType<TParams>) {
const parsedParams = schema.safeParse(params);
if (!parsedParams.success) {
const friendlyError = fromZodError(parsedParams.error, {
prefix: "There's an issue with your search params",
}).message;
return { success: false as const, error: friendlyError };
}
return { success: true as const, params: parsedParams.data };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Parses a version string into comparable numeric parts.
* Handles formats like "1.2.3", "20240115.1", "v1.0.0", plain timestamps, etc.
* Non-numeric pre-release suffixes (e.g. "-beta.1") are stripped for ordering purposes.
*/
function parseVersionParts(version: string): number[] {
const cleaned = version.replace(/^v/i, "").replace(/[-+].*$/, "");
return cleaned.split(".").map((p) => {
const n = parseInt(p, 10);
return isNaN(n) ? 0 : n;
});
}
/**
* Compares two version strings using numeric segment comparison (descending).
* Falls back to lexicographic comparison when segments are equal.
* Returns a negative number if `a` should come before `b` (i.e. `a` is newer).
*/
export function compareVersionsDescending(a: string, b: string): number {
const partsA = parseVersionParts(a);
const partsB = parseVersionParts(b);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] ?? 0;
const segB = partsB[i] ?? 0;
if (segA !== segB) {
return segB - segA;
}
}
return b.localeCompare(a);
}
/**
* Sorts an array of version strings in descending order (newest first).
* Non-destructive returns a new array.
*/
export function sortVersionsDescending(versions: string[]): string[] {
return [...versions].sort(compareVersionsDescending);
}
@@ -0,0 +1,26 @@
import type { Event, EventHint } from "@sentry/remix";
import { tenantContext } from "../services/tenantContext.server";
export function addTenantContextToEvent(event: Event, _hint: EventHint): Event {
const ctx = tenantContext.get();
if (!ctx) return event;
return {
...event,
// Only stamp user.id when we have a real user — keeps "Users Impacted"
// counting distinct humans rather than mixing in tenants. Events without
// a known user (e.g. unauthenticated paths) skip user attribution.
...(ctx.userId ? { user: { ...event.user, id: ctx.userId } } : {}),
tags: {
...event.tags,
...(ctx.orgSlug ? { org_slug: ctx.orgSlug } : {}),
...(ctx.projectSlug ? { project_slug: ctx.projectSlug } : {}),
...(ctx.envSlug ? { env_slug: ctx.envSlug } : {}),
...(ctx.orgId ? { org_id: ctx.orgId } : {}),
...(ctx.projectId ? { project_id: ctx.projectId } : {}),
...(ctx.projectRef ? { project_ref: ctx.projectRef } : {}),
...(ctx.envId ? { environment_id: ctx.envId } : {}),
...(ctx.envType ? { env_type: ctx.envType } : {}),
...(ctx.impersonating ? { impersonating: "true" } : {}),
},
};
}
@@ -0,0 +1,52 @@
import { type Span, TraceFlags, trace } from "@opentelemetry/api";
import type { Event, EventHint } from "@sentry/remix";
export type GetActiveSpan = () => Span | undefined;
const defaultGetActiveSpan: GetActiveSpan = () => trace.getActiveSpan();
export function getActiveTraceIds(
getActiveSpan: GetActiveSpan = defaultGetActiveSpan
): { traceId: string; spanId: string; sampled: boolean } | undefined {
try {
const span = getActiveSpan();
if (!span) return undefined;
const ctx = span.spanContext();
return {
traceId: ctx.traceId,
spanId: ctx.spanId,
sampled: (ctx.traceFlags & TraceFlags.SAMPLED) !== 0,
};
} catch {
return undefined;
}
}
export function addOtelTraceContextToEvent(
event: Event,
_hint: EventHint,
getActiveSpan: GetActiveSpan = defaultGetActiveSpan
): Event {
const ids = getActiveTraceIds(getActiveSpan);
if (!ids) return event;
// We intentionally overwrite Sentry's own trace_id/span_id on contexts.trace.
// With skipOpenTelemetrySetup: true, Sentry generates an internal trace_id
// unrelated to OTel; replacing it with the active OTel ids is the whole
// point of this processor — it makes Sentry issues navigable to the
// corresponding OTel trace in any backend.
return {
...event,
contexts: {
...event.contexts,
trace: {
...event.contexts?.trace,
trace_id: ids.traceId,
span_id: ids.spanId,
},
},
tags: {
...event.tags,
otel_sampled: ids.sampled ? "true" : "false",
},
};
}
+6
View File
@@ -0,0 +1,6 @@
export function singleton<T>(name: string, getValue: () => T): T {
const thusly = globalThis as any;
thusly.__trigger_singletons ??= {};
thusly.__trigger_singletons[name] ??= getValue();
return thusly.__trigger_singletons[name];
}
+87
View File
@@ -0,0 +1,87 @@
import { eventStream } from "remix-utils/sse/server";
import { env } from "~/env.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { logger } from "~/services/logger.server";
type SseProps = {
request: Request;
pingInterval?: number;
updateInterval?: number;
run: (send: (event: Event) => void, stop: () => void) => void;
};
type Event = {
/**
* @default "update"
*/
event?: string;
data: string;
};
export function sse({ request, pingInterval = 1000, updateInterval = 348, run }: SseProps) {
if (env.DISABLE_SSE === "1" || env.DISABLE_SSE === "true") {
return new Response("SSE disabled", { status: 200 });
}
const signal = getRequestAbortSignal();
let pinger: NodeJS.Timeout | undefined = undefined;
let updater: NodeJS.Timeout | undefined = undefined;
let timeout: NodeJS.Timeout | undefined = undefined;
const abort = () => {
clearInterval(pinger);
clearInterval(updater);
clearTimeout(timeout);
};
return eventStream(signal, (send, close) => {
const safeSend = (args: { event?: string; data: string }) => {
try {
send(args);
} catch (error) {
if (error instanceof Error) {
if (error.name !== "TypeError") {
logger.debug("Error sending SSE, aborting", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
args,
});
}
} else {
logger.debug("Unknown error sending SSE, aborting", {
error,
args,
});
}
close();
}
};
pinger = setInterval(() => {
if (signal.aborted) {
return abort();
}
safeSend({ event: "ping", data: new Date().toISOString() });
}, pingInterval);
updater = setInterval(() => {
if (signal.aborted) {
return abort();
}
run(safeSend, abort);
}, updateInterval);
timeout = setTimeout(() => {
close(); // close the connection after 1 minute of inactivity, which will refresh the connection (that's why we aren't using abort)
}, 60 * 1000); // 1 minute
return abort;
});
}
+239
View File
@@ -0,0 +1,239 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { type Params } from "@remix-run/router";
import { eventStream } from "remix-utils/sse/server";
import { setInterval } from "timers/promises";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
export type SendFunction = Parameters<Parameters<typeof eventStream>[1]>[0];
type HandlerParams = {
send: SendFunction;
};
type SSEHandlers = {
/** Return false to stop */
beforeStream?: () => Promise<boolean | void> | boolean | void;
/** Return false to stop */
initStream?: (params: HandlerParams) => Promise<boolean | void> | boolean | void;
/** Return false to stop */
iterator?: (params: HandlerParams & { date: Date }) => Promise<boolean | void> | boolean | void;
cleanup?: (params: HandlerParams) => void;
};
type SSEContext = {
id: string;
request: Request;
params: Params<string>;
controller: AbortController;
debug: (message: string) => void;
};
type SSEOptions = {
timeout: number;
interval?: number;
debug?: boolean;
handler: (context: SSEContext) => Promise<SSEHandlers>;
};
// This is used to track the open connections, for debugging
const connections: Set<string> = new Set();
// Stackless sentinel reasons passed to AbortController#abort. Calling .abort()
// with no argument produces a DOMException that captures a ~500-byte stack
// trace; a string reason is stored verbatim with no stack. The choice of
// reason type does not cause the retention we saw in prod (that was the
// AbortSignal.any composite — see comment near the timeoutTimer below for the
// Node issue refs), but naming the sentinels keeps call sites readable and
// lets future signal.reason consumers branch on the cause.
export const ABORT_REASON_REQUEST = "request_aborted";
export const ABORT_REASON_TIMEOUT = "timeout";
export const ABORT_REASON_SEND_ERROR = "send_error";
export const ABORT_REASON_INIT_STOP = "init_requested_stop";
export const ABORT_REASON_ITERATOR_STOP = "iterator_requested_stop";
export const ABORT_REASON_ITERATOR_ERROR = "iterator_error";
export function createSSELoader(options: SSEOptions) {
const { timeout, interval = 500, debug = false, handler } = options;
return async function loader({ request, params }: LoaderFunctionArgs) {
const id = request.headers.get("x-request-id") || Math.random().toString(36).slice(2, 8);
const internalController = new AbortController();
const log = (message: string) => {
if (debug)
console.log(
`SSE: [${request.url} ${id}] ${message} (${connections.size} open connections)`
);
};
const createSafeSend = (originalSend: SendFunction): SendFunction => {
return (event) => {
try {
if (!internalController.signal.aborted) {
originalSend(event);
}
} catch (error) {
if (error instanceof Error) {
if (error.message?.includes("Controller is already closed")) {
return;
}
log(`Error sending event: ${error.message}`);
}
// Abort before rethrowing so timer + request-abort listener are cleaned
// up immediately. Otherwise a send-failure in initStream leaves them
// alive until `timeout` fires.
if (!internalController.signal.aborted) {
internalController.abort(ABORT_REASON_SEND_ERROR);
}
throw error;
}
};
};
const context: SSEContext = {
id,
request,
params,
controller: internalController,
debug: log,
};
const handlers = await handler(context).catch((error) => {
if (error instanceof Response) {
throw error;
}
throw new Response("Internal Server Error", { status: 500 });
});
const requestAbortSignal = getRequestAbortSignal();
log("Start");
// Single-signal abort chain: everything rolls up into internalController.
// Timeout is a plain setTimeout cleared on abort rather than an
// AbortSignal.timeout() combined via AbortSignal.any() — AbortSignal.any
// keeps its source signals in an internal Set<WeakRef> managed by a
// FinalizationRegistry, and under sustained request traffic those entries
// accumulate faster than they get cleaned up, pinning every source signal
// (and its listeners, and anything those listeners close over) until the
// parent signal is GC'd or aborts. Reproduced locally in isolation; shape
// matches the ChainSafe Lodestar production case described in
// nodejs/node#54614. See also nodejs/node#55351 (mechanism confirmed by
// @jasnell, narrow fix in 22.12.0 via #55354) and nodejs/node#57584
// (circular-dep variant, still open).
const timeoutTimer = setTimeout(() => {
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_TIMEOUT);
}, timeout);
const onRequestAbort = () => {
log("request signal aborted");
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_REQUEST);
};
internalController.signal.addEventListener(
"abort",
() => {
clearTimeout(timeoutTimer);
requestAbortSignal.removeEventListener("abort", onRequestAbort);
},
{ once: true }
);
// The request could have been aborted during `await handler(context)` above.
// AbortSignal listeners added after the signal is already aborted never fire,
// so invoke cleanup synchronously in that case instead of waiting for `timeout`.
if (requestAbortSignal.aborted) {
onRequestAbort();
} else {
requestAbortSignal.addEventListener("abort", onRequestAbort, { once: true });
}
if (handlers.beforeStream) {
const shouldContinue = await handlers.beforeStream();
if (shouldContinue === false) {
log("beforeStream returned false, so we'll exit before creating the stream");
internalController.abort(ABORT_REASON_INIT_STOP);
return;
}
}
return eventStream(internalController.signal, function setup(send) {
connections.add(id);
const safeSend = createSafeSend(send);
async function run() {
try {
log("Initializing");
if (handlers.initStream) {
const shouldContinue = await handlers.initStream({ send: safeSend });
if (shouldContinue === false) {
log("initStream returned false, so we'll stop the stream");
internalController.abort(ABORT_REASON_INIT_STOP);
return;
}
}
log("Starting interval");
for await (const _ of setInterval(interval, null, {
signal: internalController.signal,
})) {
log("PING");
const date = new Date();
if (handlers.iterator) {
try {
const shouldContinue = await handlers.iterator({ date, send: safeSend });
if (shouldContinue === false) {
log("iterator return false, so we'll stop the stream");
internalController.abort(ABORT_REASON_ITERATOR_STOP);
break;
}
} catch (error) {
log("iterator threw an error, aborting stream");
// Immediately abort to trigger cleanup
if (error instanceof Error && error.name !== "AbortError") {
log(`iterator error: ${error.message}`);
}
internalController.abort(ABORT_REASON_ITERATOR_ERROR);
// No need to re-throw as we're handling it by aborting
return; // Exit the run function immediately
}
}
}
log("iterator finished all iterations");
} catch (error) {
if (error instanceof Error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
} finally {
log("iterator finished");
}
}
run();
return () => {
connections.delete(id);
log("Cleanup called");
if (handlers.cleanup) {
try {
handlers.cleanup({ send: safeSend });
} catch (error) {
log(
`Error in cleanup handler: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
console.error("SSE Cleanup Error:", error);
}
}
};
});
};
}
+9
View File
@@ -0,0 +1,9 @@
// Shared (server + client) constants for the SSO session-revalidation flow.
export const SSO_SESSION_EXPIRED_REASON = "session_expired";
// The reason rides as its own `?reason=` param, not `?redirectTo=/login...`,
// because the redirect sanitizer rejects /login and would drop it.
export function ssoSessionExpiredLogoutPath(): string {
return `/logout?reason=${SSO_SESSION_EXPIRED_REASON}`;
}
+3
View File
@@ -0,0 +1,3 @@
export function capitalizeWord(word: string) {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
type InputType = { id: string; parentId: string | null };
export type OutputType<T> = T & { subtasks?: T[] };
export function taskListToTree<T extends InputType>(
tasks: T[],
addSubtasks = true
): OutputType<T>[] {
if (!addSubtasks) {
return tasks;
}
const result: OutputType<T>[] = [];
const map = new Map<string, T>(tasks.map((v) => [v.id, v]));
for (const node of tasks) {
const parent: OutputType<T> | null = node.parentId
? (map.get(node.parentId) as OutputType<T>)
: null;
if (parent) {
if (!parent.subtasks) {
parent.subtasks = [] as any;
}
parent.subtasks!.push(node as any);
} else {
result.push(node as any);
}
}
return result;
}
+20
View File
@@ -0,0 +1,20 @@
//From: https://kettanaito.com/blog/debounce-vs-throttle
/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */
export function throttle<TArgs extends unknown[]>(
func: (...args: TArgs) => void,
durationMs: number
): (...args: TArgs) => void {
let isPrimedToFire = false;
return (...args: TArgs) => {
if (!isPrimedToFire) {
isPrimedToFire = true;
setTimeout(() => {
func(...args);
isPrimedToFire = false;
}, durationMs);
}
};
}
+61
View File
@@ -0,0 +1,61 @@
import { z } from "zod";
import parseDuration from "parse-duration";
const DurationString = z.string().refine(
(val) => {
const ms = parseDuration(val);
return ms !== null && ms > 0;
},
(val) => ({ message: `Invalid or non-positive duration string: "${val}"` })
);
const BracketSchema = z.object({
max: z.union([z.literal("Infinity"), DurationString]),
granularity: DurationString,
});
const BracketsSchema = z
.array(BracketSchema)
.min(1, "TimeGranularity requires at least one bracket");
export type TimeGranularityBracket = z.input<typeof BracketSchema>;
type ParsedBracket = {
maxMs: number;
granularityMs: number;
};
function requireParsedDuration(input: string): number {
const ms = parseDuration(input);
if (ms === null || ms <= 0) {
throw new Error(`Duration must be strictly positive, got "${input}" (${ms}ms)`);
}
return ms;
}
export class TimeGranularity {
private readonly parsed: ParsedBracket[];
constructor(brackets: TimeGranularityBracket[]) {
const validated = BracketsSchema.parse(brackets);
this.parsed = validated.map((b) => ({
maxMs: b.max === "Infinity" ? Infinity : requireParsedDuration(b.max),
granularityMs: requireParsedDuration(b.granularity),
}));
}
getTimeGranularityMs(from: Date, to: Date): number {
if (from.getTime() > to.getTime()) {
return this.parsed[this.parsed.length - 1].granularityMs;
}
const rangeMs = to.getTime() - from.getTime();
for (const bracket of this.parsed) {
if (rangeMs <= bracket.maxMs) {
return bracket.granularityMs;
}
}
return this.parsed[this.parsed.length - 1].granularityMs;
}
}
+223
View File
@@ -0,0 +1,223 @@
import type { SpanEvent } from "@trigger.dev/core/v3";
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3/utils/durations";
export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed";
export type TimelineLineVariant = "light" | "normal";
export type TimelineEventVariant =
| "start-cap"
| "dot-hollow"
| "dot-solid"
| "start-cap-thick"
| "end-cap-thick"
| "end-cap";
export type TimelineSpanEvent = {
name: string;
offset: number;
timestamp: Date;
duration?: number;
helpText?: string;
markerVariant: TimelineEventVariant;
lineVariant: TimelineLineVariant;
};
export function createTimelineSpanEventsFromSpanEvents(
spanEvents: SpanEvent[],
isAdmin: boolean,
relativeStartTime?: number
): Array<TimelineSpanEvent> {
if (!spanEvents) {
return [];
}
const matchingSpanEvents = spanEvents.filter((spanEvent) =>
spanEvent.name.startsWith("trigger.dev/")
);
if (matchingSpanEvents.length === 0) {
return [];
}
// Check if there's a fork event
const hasForkEvent = matchingSpanEvents.some(
(spanEvent) =>
"event" in spanEvent.properties &&
typeof spanEvent.properties.event === "string" &&
spanEvent.properties.event === "fork"
);
const sortedSpanEvents = [...matchingSpanEvents].sort((a, b) => {
if (a.time === b.time) {
return a.name.localeCompare(b.name);
}
const aTime = typeof a.time === "string" ? new Date(a.time) : a.time;
const bTime = typeof b.time === "string" ? new Date(b.time) : b.time;
return aTime.getTime() - bTime.getTime();
});
const visibleSpanEvents = sortedSpanEvents.filter((spanEvent) => {
const eventName =
"event" in spanEvent.properties && typeof spanEvent.properties.event === "string"
? spanEvent.properties.event
: spanEvent.name;
// If we're admin, everything is visible
if (isAdmin) {
return true;
}
// If there's no fork event, import events are also visible to non-admins
if (!hasForkEvent && eventName === "import") {
return true;
}
// Otherwise use normal admin-only logic
return !getAdminOnlyForEvent(eventName);
});
if (visibleSpanEvents.length === 0) {
return [];
}
const firstEventTime =
typeof visibleSpanEvents[0].time === "string"
? new Date(visibleSpanEvents[0].time)
: visibleSpanEvents[0].time;
const $relativeStartTime = relativeStartTime ?? firstEventTime.getTime();
const events = visibleSpanEvents.map((spanEvent, index) => {
const timestamp =
typeof spanEvent.time === "string" ? new Date(spanEvent.time) : spanEvent.time;
const offset = millisecondsToNanoseconds(timestamp.getTime() - $relativeStartTime);
const duration =
"duration" in spanEvent.properties && typeof spanEvent.properties.duration === "number"
? spanEvent.properties.duration
: undefined;
const name =
"event" in spanEvent.properties && typeof spanEvent.properties.event === "string"
? spanEvent.properties.event
: spanEvent.name;
let markerVariant: TimelineEventVariant = "dot-hollow";
if (index === 0) {
markerVariant = "start-cap";
}
return {
name: getFriendlyNameForEvent(name, spanEvent.properties),
offset,
timestamp,
duration,
helpText: getHelpTextForEvent(name),
markerVariant,
lineVariant: "light" as const,
};
});
// Now sort by offset, ascending
events.sort((a, b) => a.offset - b.offset);
return events;
}
function getFriendlyNameForEvent(event: string, properties?: Record<string, any>): string {
switch (event) {
case "dequeue": {
return "Dequeued";
}
case "fork": {
return "Launched";
}
case "create_attempt": {
return "Attempt created";
}
case "import": {
return "Importing task file";
}
case "lazy_payload": {
return "Lazy attempt initialized";
}
case "pod_scheduled": {
return "Pod scheduled";
}
default: {
return event;
}
}
}
function getAdminOnlyForEvent(event: string): boolean {
switch (event) {
case "dequeue": {
return false;
}
case "fork": {
return false;
}
case "create_attempt": {
return true;
}
case "import": {
return false;
}
case "lazy_payload": {
return true;
}
case "pod_scheduled": {
return true;
}
default: {
return true;
}
}
}
export function getHelpTextForEvent(event: string): string | undefined {
switch (event) {
case "dequeue": {
return "The run was dequeued from the queue";
}
case "fork": {
return "The process was created to run the task";
}
case "create_attempt": {
return "An attempt was created for the run";
}
case "import": {
return "A task file was imported";
}
case "lazy_payload": {
return "The payload was initialized lazily";
}
case "pod_scheduled": {
return "The Kubernetes pod was scheduled to run";
}
case "Triggered": {
return "The run was triggered";
}
case "Dequeued": {
return "The run was dequeued from the queue";
}
case "Started": {
return "The run began executing";
}
case "Finished": {
return "The run completed execution";
}
case "Expired": {
return "The run expired before it could be started";
}
default: {
return undefined;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
export function getTimezones(includeUtc = true) {
const possibleTimezones = Intl.supportedValuesOf("timeZone").sort();
if (includeUtc) {
possibleTimezones.unshift("UTC");
}
return possibleTimezones;
}
/**
* Whether the runtime can resolve this IANA timezone. Prefer this over checking membership
* in `Intl.supportedValuesOf("timeZone")`, which lists only canonical ids and omits zones
* browsers legitimately report (e.g. "UTC", "Etc/UTC", "Asia/Kolkata") — rejecting those
* would leave a client's stored timezone stale.
*/
export function isValidTimeZone(timeZone: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone });
return true;
} catch {
return false;
}
}
+34
View File
@@ -0,0 +1,34 @@
import nodeCrypto from "node:crypto";
export function encryptToken(value: string, key: string) {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
export function decryptToken(nonce: string, ciphertext: string, tag: string, key: string): string {
const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "hex"));
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
export function hashToken(token: string): string {
const hash = nodeCrypto.createHash("sha256");
hash.update(token);
return hash.digest("hex");
}
+9
View File
@@ -0,0 +1,9 @@
const ALLOWED_TRIGGER_SOURCES = new Set(["sdk", "cli", "mcp"]);
/** Validates a client-provided trigger source header against the allowlist. */
export function sanitizeTriggerSource(value: string | null | undefined): string | undefined {
if (value && ALLOWED_TRIGGER_SOURCES.has(value)) {
return value;
}
return undefined;
}
+22
View File
@@ -0,0 +1,22 @@
import type { User as DBUser } from "~/models/user.server";
type User = Pick<DBUser, "name" | "displayName">;
// remove `null` from username
export function getUsername(user?: User): string | undefined {
if (!user) {
return;
}
// user.displayName is of type `string | null`
if (user.displayName) {
return user.displayName;
}
// user.name is of type `string | null`
if (user.name) {
return user.name;
}
return;
}
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod";
export const CoercedDate = z.preprocess((arg) => {
if (arg === undefined || arg === null) {
return;
}
if (typeof arg === "number") {
return new Date(arg);
}
if (typeof arg === "string") {
const num = Number(arg);
if (!isNaN(num)) {
return new Date(num);
}
return new Date(arg);
}
return arg;
}, z.date().optional());