feat(webapp): admin UI for feature flag overrides (#3291)

Adds a dialog to the admin orgs page for viewing and editing per-org
feature flag overrides. Flags are introspected from the catalog so the
UI stays in sync with available flags automatically. Also adds a new tab
for global flags.

Refactors featureFlags.server.ts to split catalog definition (shared)
from server-only runtime (flags(), makeSetMultipleFlags). The shared
module exports flag metadata and validation so both the UI and API
routes can use it without pulling in server dependencies.
This commit is contained in:
nicktrn
2026-03-30 16:55:23 +01:00
committed by GitHub
parent 7210bdee9b
commit 5447cf4843
26 changed files with 1301 additions and 86 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add admin UI for viewing and editing feature flags (org-level overrides and global defaults).
+9
View File
@@ -98,3 +98,12 @@ The `app/v3/` directory name is misleading - most code is actively used by V2. O
- `app/v3/sharedSocketConnection.ts`
Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths.
## Prisma Query Patterns
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.
## React Patterns
- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations.
- Use named constants for sentinel/placeholder values (e.g. `const UNSET_VALUE = "__unset__"`) instead of raw string literals scattered across comparisons.
@@ -0,0 +1,290 @@
import { useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";
import stableStringify from "json-stable-stringify";
import {
Dialog,
DialogContent,
DialogHeader,
DialogDescription,
DialogFooter,
} from "~/components/primitives/Dialog";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { LockClosedIcon } from "@heroicons/react/20/solid";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { cn } from "~/utils/cn";
import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags";
import {
UNSET_VALUE,
BooleanControl,
EnumControl,
StringControl,
WorkerGroupControl,
type WorkerGroup,
} from "./FlagControls";
type LoaderData = {
org: { id: string; title: string; slug: string };
orgFlags: Record<string, unknown>;
globalFlags: Record<string, unknown>;
controlTypes: Record<string, FlagControlType>;
workerGroupName?: string;
workerGroups?: WorkerGroup[];
isManagedCloud?: boolean;
};
type ActionData = {
success?: boolean;
error?: string;
};
type FeatureFlagsDialogProps = {
orgId: string | null;
orgTitle: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function FeatureFlagsDialog({
orgId,
orgTitle,
open,
onOpenChange,
}: FeatureFlagsDialogProps) {
const loadFetcher = useFetcher<LoaderData>();
const saveFetcher = useFetcher<ActionData>();
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [unlocked, setUnlocked] = useState(false);
const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key);
useEffect(() => {
if (open && orgId) {
setSaveError(null);
setOverrides({});
setInitialOverrides({});
loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`);
}
}, [open, orgId]);
useEffect(() => {
if (loadFetcher.data) {
const loaded = loadFetcher.data.orgFlags ?? {};
setOverrides({ ...loaded });
setInitialOverrides({ ...loaded });
}
}, [loadFetcher.data]);
useEffect(() => {
if (saveFetcher.data?.success) {
onOpenChange(false);
} else if (saveFetcher.data?.error) {
setSaveError(saveFetcher.data.error);
}
}, [saveFetcher.data]);
const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides);
const setFlagValue = (key: string, value: unknown) => {
setOverrides((prev) => ({ ...prev, [key]: value }));
};
const unsetFlag = (key: string) => {
setOverrides((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
const handleSave = () => {
if (!orgId) return;
const body = Object.keys(overrides).length === 0 ? null : overrides;
saveFetcher.submit(JSON.stringify(body), {
method: "POST",
action: `/admin/api/v2/orgs/${orgId}/feature-flags`,
encType: "application/json",
});
};
const data = loadFetcher.data;
const isLoading = loadFetcher.state === "loading";
const isSaving = saveFetcher.state === "submitting";
const jsonPreview =
Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2);
const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>Feature flags - {orgTitle}</DialogHeader>
<DialogDescription>
Org-level overrides. Unset flags inherit from global defaults.
</DialogDescription>
{data && (
<div className={data.isManagedCloud ? "cursor-not-allowed" : undefined}>
<CheckboxWithLabel
variant="simple/small"
label={
data.isManagedCloud
? "Unlock read-only flags (only in unmanaged cloud)"
: "Unlock read-only flags"
}
defaultChecked={unlocked}
onChange={setUnlocked}
disabled={data.isManagedCloud}
className={data.isManagedCloud ? "pointer-events-none" : undefined}
/>
</div>
)}
<div className="max-h-[60vh] overflow-y-auto">
{isLoading ? (
<div className="py-8 text-center text-sm text-text-dimmed">Loading flags...</div>
) : data ? (
<div className="flex flex-col gap-1.5">
{sortedFlagKeys.map((key) => {
const control = data.controlTypes[key];
const locked = isLocked(key);
const globalValue = data.globalFlags[key as keyof typeof data.globalFlags];
const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId;
const globalDisplay =
isWorkerGroup && data.workerGroupName && globalValue !== undefined
? `${data.workerGroupName} (${String(globalValue).slice(0, 8)}...)`
: globalValue !== undefined
? String(globalValue)
: "unset";
if (locked) {
return (
<div
key={key}
className="flex items-center justify-between rounded-md border border-transparent bg-charcoal-750 px-3 py-2.5"
title="Global-level setting - not editable per org"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-text-dimmed">{key}</div>
<div className="text-xs text-charcoal-400">global: {globalDisplay}</div>
</div>
<LockClosedIcon className="size-4 text-charcoal-500" />
</div>
);
}
const isOverridden = key in overrides;
return (
<div
key={key}
className={cn(
"flex items-center justify-between rounded-md border px-3 py-2.5",
isOverridden
? "border-indigo-500/20 bg-indigo-500/5"
: "border-transparent bg-charcoal-750"
)}
>
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm",
isOverridden ? "text-text-bright" : "text-text-dimmed"
)}
>
{key}
</div>
<div className="text-xs text-charcoal-400">global: {globalDisplay}</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="minimal/small"
onClick={() => unsetFlag(key)}
className={cn(!isOverridden && "invisible")}
>
unset
</Button>
{isWorkerGroup && data.workerGroups ? (
<WorkerGroupControl
value={isOverridden ? (overrides[key] as string) : undefined}
workerGroups={data.workerGroups as WorkerGroup[]}
onChange={(val) => {
if (val === UNSET_VALUE) {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isOverridden}
/>
) : control.type === "boolean" ? (
<BooleanControl
value={isOverridden ? (overrides[key] as boolean) : undefined}
onChange={(val) => setFlagValue(key, val)}
dimmed={!isOverridden}
/>
) : control.type === "enum" ? (
<EnumControl
value={isOverridden ? (overrides[key] as string) : undefined}
options={control.options}
onChange={(val) => {
if (val === UNSET_VALUE) {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isOverridden}
/>
) : control.type === "string" ? (
<StringControl
value={isOverridden ? (overrides[key] as string) : ""}
onChange={(val) => {
if (val === "") {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isOverridden}
/>
) : null}
</div>
</div>
);
})}
</div>
) : null}
</div>
{data && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-text-dimmed hover:text-text-bright">
Preview JSON
</summary>
<pre className="mt-1 max-h-40 overflow-auto rounded bg-charcoal-800 p-2 text-xs text-text-dimmed">
{jsonPreview}
</pre>
</details>
)}
{saveError && <Callout variant="error">{saveError}</Callout>}
<DialogFooter>
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="primary/small" onClick={handleSave} disabled={!isDirty || isSaving}>
{isSaving ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,120 @@
import { Switch } from "~/components/primitives/Switch";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Input } from "~/components/primitives/Input";
import { cn } from "~/utils/cn";
export const UNSET_VALUE = "__unset__";
export function BooleanControl({
value,
onChange,
dimmed,
}: {
value: boolean | undefined;
onChange: (val: boolean) => void;
dimmed: boolean;
}) {
return (
<Switch
variant="small"
checked={value ?? false}
onCheckedChange={onChange}
className={cn(dimmed && "opacity-50")}
/>
);
}
export function EnumControl({
value,
options,
onChange,
dimmed,
}: {
value: string | undefined;
options: string[];
onChange: (val: string) => void;
dimmed: boolean;
}) {
const items = [UNSET_VALUE, ...options];
return (
<Select
variant="tertiary/small"
value={value ?? UNSET_VALUE}
setValue={onChange}
items={items}
text={(val) => (val === UNSET_VALUE ? "unset" : val)}
className={cn(dimmed && "opacity-50")}
>
{(items) =>
items.map((item) => (
<SelectItem key={item} value={item}>
{item === UNSET_VALUE ? "unset" : item}
</SelectItem>
))
}
</Select>
);
}
export type WorkerGroup = { id: string; name: string };
export function WorkerGroupControl({
value,
workerGroups,
onChange,
dimmed,
}: {
value: string | undefined;
workerGroups: WorkerGroup[];
onChange: (val: string) => void;
dimmed: boolean;
}) {
const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)];
return (
<Select
variant="tertiary/small"
value={value ?? UNSET_VALUE}
setValue={onChange}
items={items}
text={(val) => {
if (val === UNSET_VALUE) return "unset";
const wg = workerGroups.find((w) => w.id === val);
return wg ? wg.name : val;
}}
className={cn(dimmed && "opacity-50")}
>
{(items) =>
items.map((item) => {
const wg = workerGroups.find((w) => w.id === item);
return (
<SelectItem key={item} value={item}>
{item === UNSET_VALUE ? "unset" : wg ? wg.name : item}
</SelectItem>
);
})
}
</Select>
);
}
export function StringControl({
value,
onChange,
dimmed,
}: {
value: string;
onChange: (val: string) => void;
dimmed: boolean;
}) {
return (
<Input
variant="small"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="unset"
className={cn("w-40", dimmed && "opacity-50")}
/>
);
}
@@ -11,7 +11,8 @@ import {
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { env } from "~/env.server";
import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";
import { flags } from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -1,6 +1,7 @@
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
import { BasePresenter } from "./basePresenter.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
@@ -37,7 +37,7 @@ import {
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Button } from "~/components/primitives/Buttons";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags";
// Valid log levels for filtering
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
@@ -1,7 +1,8 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { makeSetMultipleFlags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";
import { makeSetMultipleFlags } from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
export async function action({ request }: ActionFunctionArgs) {
// Next authenticate the request
@@ -11,7 +12,7 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
const user = await prisma.user.findFirst({
where: {
id: authenticationResult.userId,
},
@@ -2,7 +2,7 @@ import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
const ParamsSchema = z.object({
organizationId: z.string(),
@@ -15,7 +15,7 @@ async function authenticateAdmin(request: Request) {
return { error: json({ error: "Invalid or Missing API key" }, { status: 401 }) };
}
const user = await prisma.user.findUnique({
const user = await prisma.user.findFirst({
where: {
id: authenticationResult.userId,
},
@@ -41,7 +41,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const { organizationId } = ParamsSchema.parse(params);
const organization = await prisma.organization.findUnique({
const organization = await prisma.organization.findFirst({
where: {
id: organizationId,
},
@@ -78,7 +78,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { organizationId } = ParamsSchema.parse(params);
const organization = await prisma.organization.findUnique({
const organization = await prisma.organization.findFirst({
where: {
id: organizationId,
},
@@ -0,0 +1,132 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { Prisma } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, validatePartialFeatureFlags, getAllFlagControlTypes } from "~/v3/featureFlags";
import { featuresForRequest } from "~/features.server";
// Session-auth route for the admin feature flags dialog.
// Uses replace semantics: the action writes the full flag set (or null to clear).
// Compare with v1 (admin.api.v1.orgs.$organizationId.feature-flags.ts) which
// uses PAT auth and merge semantics for programmatic use.
const ParamsSchema = z.object({
organizationId: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
throw new Response("Unauthorized", { status: 403 });
}
const { organizationId } = ParamsSchema.parse(params);
const [organization, globalFlags, workerGroups] = await Promise.all([
prisma.organization.findFirst({
where: { id: organizationId },
select: {
id: true,
title: true,
slug: true,
featureFlags: true,
},
}),
getGlobalFlags(),
prisma.workerInstanceGroup.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
if (!organization) {
throw new Response("Organization not found", { status: 404 });
}
const orgFlagsResult = organization.featureFlags
? validatePartialFeatureFlags(organization.featureFlags as Record<string, unknown>)
: ({ success: false } as const);
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
const controlTypes = getAllFlagControlTypes();
// Resolve worker group name for display
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
FEATURE_FLAG.defaultWorkerInstanceGroupId
];
let workerGroupName: string | undefined;
if (typeof workerGroupId === "string") {
const wg = await prisma.workerInstanceGroup.findFirst({
where: { id: workerGroupId },
select: { name: true },
});
workerGroupName = wg?.name;
}
const { isManagedCloud } = featuresForRequest(request);
return json({
org: {
id: organization.id,
title: organization.title,
slug: organization.slug,
},
orgFlags,
globalFlags,
controlTypes,
workerGroupName,
workerGroups,
isManagedCloud,
});
}
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
throw new Response("Unauthorized", { status: 403 });
}
const { organizationId } = ParamsSchema.parse(params);
let body: unknown;
try {
body = await request.json();
} catch {
return json({ error: "Invalid JSON body" }, { status: 400 });
}
let featureFlags: typeof Prisma.JsonNull | Record<string, unknown>;
if (
body === null ||
(typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0)
) {
featureFlags = Prisma.JsonNull;
} else {
const validationResult = validatePartialFeatureFlags(body as Record<string, unknown>);
if (!validationResult.success) {
return json(
{ error: "Invalid feature flags", details: validationResult.error.issues },
{ status: 400 }
);
}
featureFlags = validationResult.data;
}
try {
await prisma.organization.update({
where: { id: organizationId },
data: { featureFlags: featureFlags as Prisma.InputJsonValue },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
throw new Response("Organization not found", { status: 404 });
}
throw e;
}
return json({ success: true });
}
@@ -0,0 +1,565 @@
import { useFetcher } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import stableStringify from "json-stable-stringify";
import { json } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LockClosedIcon } from "@heroicons/react/20/solid";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { requireUser } from "~/services/session.server";
import {
FEATURE_FLAG,
GLOBAL_LOCKED_FLAGS,
type FlagControlType,
getAllFlagControlTypes,
validatePartialFeatureFlags,
} from "~/v3/featureFlags";
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
import { featuresForRequest } from "~/features.server";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogDescription,
DialogFooter,
} from "~/components/primitives/Dialog";
import { cn } from "~/utils/cn";
import {
UNSET_VALUE,
BooleanControl,
EnumControl,
StringControl,
WorkerGroupControl,
type WorkerGroup,
} from "~/components/admin/FlagControls";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
}
const [globalFlags, workerGroups] = await Promise.all([
getGlobalFlags(),
prisma.workerInstanceGroup.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
const controlTypes = getAllFlagControlTypes();
// Resolve env-based defaults for locked flags
const resolvedDefaults: Record<string, string> = {
[FEATURE_FLAG.runsListRepository]: "clickhouse",
[FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE,
};
// Look up worker group name if the flag is set
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
FEATURE_FLAG.defaultWorkerInstanceGroupId
];
const workerGroupName =
typeof workerGroupId === "string"
? workerGroups.find((wg) => wg.id === workerGroupId)?.name
: undefined;
const { isManagedCloud } = featuresForRequest(request);
return typedjson({
globalFlags,
controlTypes,
resolvedDefaults,
workerGroupName,
workerGroups,
isManagedCloud,
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) {
throw new Response("Unauthorized", { status: 403 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return json({ error: "Invalid JSON body" }, { status: 400 });
}
const payloadSchema = z.object({ flags: z.record(z.unknown()) });
const parsed = payloadSchema.safeParse(body);
if (!parsed.success) {
return json({ error: "Invalid payload" }, { status: 400 });
}
const { isManagedCloud } = featuresForRequest(request);
// On managed cloud, reject if payload includes locked flags
if (isManagedCloud) {
const lockedInPayload = Object.keys(parsed.data.flags).filter((key) =>
GLOBAL_LOCKED_FLAGS.includes(key)
);
if (lockedInPayload.length > 0) {
return json(
{ error: `Cannot modify locked flags: ${lockedInPayload.join(", ")}` },
{ status: 400 }
);
}
}
const validationResult = validatePartialFeatureFlags(parsed.data.flags);
if (!validationResult.success) {
return json(
{ error: "Invalid feature flags", details: validationResult.error.issues },
{ status: 400 }
);
}
const validatedFlags = validationResult.data as Record<string, unknown>;
const controlTypes = getAllFlagControlTypes();
const catalogKeys = Object.keys(controlTypes);
const keysToDelete: string[] = [];
const upsertOps: ReturnType<typeof prisma.featureFlag.upsert>[] = [];
for (const key of catalogKeys) {
if (key in validatedFlags) {
upsertOps.push(
prisma.featureFlag.upsert({
where: { key },
create: { key, value: validatedFlags[key] as any },
update: { value: validatedFlags[key] as any },
})
);
} else {
// On cloud, never delete locked flags (they're not in the payload
// because the UI doesn't include them). Locally, delete everything
// the user didn't include - full control.
const isProtected = isManagedCloud && GLOBAL_LOCKED_FLAGS.includes(key);
if (!isProtected) {
keysToDelete.push(key);
}
}
}
await prisma.$transaction([
...upsertOps,
...(keysToDelete.length > 0
? [prisma.featureFlag.deleteMany({ where: { key: { in: keysToDelete } } })]
: []),
]);
return json({ success: true });
};
export default function AdminFeatureFlagsRoute() {
const {
globalFlags,
controlTypes,
resolvedDefaults,
workerGroupName,
workerGroups,
isManagedCloud,
} = useTypedLoaderData<typeof loader>();
const saveFetcher = useFetcher<{ success?: boolean; error?: string }>();
const [values, setValues] = useState<Record<string, unknown>>({});
const [initialValues, setInitialValues] = useState<Record<string, unknown>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [unlocked, setUnlocked] = useState(false);
const isLocked = (key: string) => !unlocked && GLOBAL_LOCKED_FLAGS.includes(key);
useEffect(() => {
const loaded = (globalFlags ?? {}) as Record<string, unknown>;
// Only track editable flags in state
const editable: Record<string, unknown> = {};
for (const [key, value] of Object.entries(loaded)) {
if (!isLocked(key)) {
editable[key] = value;
}
}
setValues({ ...editable });
setInitialValues({ ...editable });
}, [globalFlags, unlocked]);
useEffect(() => {
if (saveFetcher.data?.success) {
setSaveError(null);
setConfirmOpen(false);
} else if (saveFetcher.data?.error) {
setSaveError(saveFetcher.data.error);
}
}, [saveFetcher.data]);
const isDirty = stableStringify(values) !== stableStringify(initialValues);
const isSaving = saveFetcher.state === "submitting";
const setFlagValue = (key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
};
const unsetFlag = (key: string) => {
setValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
const handleSave = () => {
saveFetcher.submit(JSON.stringify({ flags: values }), {
method: "POST",
encType: "application/json",
});
};
const typedControlTypes = controlTypes as Record<string, FlagControlType>;
const typedResolvedDefaults = resolvedDefaults as Record<string, string>;
const allFlags = (globalFlags ?? {}) as Record<string, unknown>;
const sortedFlagKeys = Object.keys(typedControlTypes).sort();
const workerGroupMap = new Map((workerGroups as WorkerGroup[]).map((wg) => [wg.id, wg.name]));
const resolveWorkerGroupDisplay = (id: string) => {
const name = workerGroupMap.get(id);
return name ? `${name} (${id.slice(0, 8)}...)` : id;
};
return (
<main className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last">
<div className="max-w-2xl space-y-4">
<p className="text-sm text-text-dimmed">
Global defaults for all organizations. Org-level overrides take precedence. When not set,
each consumer uses its own default.
</p>
<div className={isManagedCloud ? "cursor-not-allowed" : undefined}>
<CheckboxWithLabel
variant="simple/small"
label={
isManagedCloud
? "Unlock read-only flags (only in unmanaged cloud)"
: "Unlock read-only flags"
}
defaultChecked={unlocked}
onChange={setUnlocked}
disabled={isManagedCloud}
className={isManagedCloud ? "pointer-events-none" : undefined}
/>
</div>
<div className="flex flex-col gap-1.5">
{sortedFlagKeys.map((key) => {
const control = typedControlTypes[key];
const locked = isLocked(key);
if (locked) {
return (
<LockedFlagRow
key={key}
flagKey={key}
value={allFlags[key]}
resolvedDefault={typedResolvedDefaults[key]}
workerGroupName={workerGroupName as string | undefined}
/>
);
}
const isSet = key in values;
const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId;
return (
<div
key={key}
className={cn(
"flex items-center justify-between rounded-md border px-3 py-2.5",
isSet
? "border-indigo-500/20 bg-indigo-500/5"
: "border-transparent bg-charcoal-750"
)}
>
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm",
isSet ? "text-text-bright" : "text-text-dimmed"
)}
>
{isWorkerGroup ? "defaultWorkerInstanceGroup" : key}
</div>
<div className="text-xs text-charcoal-400">
{isSet
? isWorkerGroup
? resolveWorkerGroupDisplay(values[key] as string)
: `value: ${String(values[key])}`
: typedResolvedDefaults[key]
? `${typedResolvedDefaults[key]} (from env)`
: "not set"}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="minimal/small"
onClick={() => unsetFlag(key)}
className={cn(!isSet && "invisible")}
>
unset
</Button>
{isWorkerGroup ? (
<WorkerGroupControl
value={isSet ? (values[key] as string) : undefined}
workerGroups={workerGroups as WorkerGroup[]}
onChange={(val) => {
if (val === UNSET_VALUE) {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isSet}
/>
) : (
<>
{control.type === "boolean" && (
<BooleanControl
value={isSet ? (values[key] as boolean) : undefined}
onChange={(val) => setFlagValue(key, val)}
dimmed={!isSet}
/>
)}
{control.type === "enum" && (
<EnumControl
value={isSet ? (values[key] as string) : undefined}
options={control.options}
onChange={(val) => {
if (val === UNSET_VALUE) {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isSet}
/>
)}
{control.type === "string" && (
<StringControl
value={isSet ? (values[key] as string) : ""}
onChange={(val) => {
if (val === "") {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isSet}
/>
)}
</>
)}
</div>
</div>
);
})}
</div>
{saveError && <Callout variant="error">{saveError}</Callout>}
<div className="flex justify-end gap-2">
{isDirty && (
<Button variant="tertiary/small" onClick={() => setValues({ ...initialValues })}>
Discard
</Button>
)}
<Button
variant="primary/small"
onClick={() => setConfirmOpen(true)}
disabled={!isDirty || isSaving}
>
Review changes
</Button>
</div>
</div>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
initialValues={initialValues}
newValues={values}
controlTypes={typedControlTypes}
lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS}
onConfirm={handleSave}
isSaving={isSaving}
/>
</main>
);
}
// --- Locked Flag Row ---
function LockedFlagRow({
flagKey,
value,
resolvedDefault,
workerGroupName,
}: {
flagKey: string;
value: unknown;
resolvedDefault: string | undefined;
workerGroupName: string | undefined;
}) {
const isSet = value !== undefined;
const isWorkerGroup = flagKey === FEATURE_FLAG.defaultWorkerInstanceGroupId;
let displayValue: string;
if (isSet) {
if (isWorkerGroup && workerGroupName) {
displayValue = `${workerGroupName} (${String(value).slice(0, 8)}...)`;
} else {
displayValue = String(value);
}
} else if (resolvedDefault) {
displayValue = `${resolvedDefault} (from env)`;
} else {
displayValue = "not set (required)";
}
return (
<div
className={cn(
"flex items-center justify-between rounded-md border px-3 py-2.5",
isSet ? "border-indigo-500/20 bg-indigo-500/5" : "border-transparent bg-charcoal-750"
)}
title="Managed via database - not editable from this UI"
>
<div className="min-w-0 flex-1">
<div
className={cn("truncate text-sm", isSet ? "text-text-bright" : "text-text-dimmed")}
>
{isWorkerGroup ? "defaultWorkerInstanceGroup" : flagKey}
</div>
<div className="text-xs text-charcoal-400">{displayValue}</div>
</div>
<LockClosedIcon className="size-4 text-charcoal-500" />
</div>
);
}
// --- Confirmation Dialog with Diff ---
function ConfirmDialog({
open,
onOpenChange,
initialValues,
newValues,
controlTypes,
lockedKeys,
onConfirm,
isSaving,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
initialValues: Record<string, unknown>;
newValues: Record<string, unknown>;
controlTypes: Record<string, FlagControlType>;
lockedKeys: readonly string[];
onConfirm: () => void;
isSaving: boolean;
}) {
const editableKeys = Object.keys(controlTypes)
.filter((key) => !lockedKeys.includes(key))
.sort();
type Change =
| { key: string; type: "added"; newVal: string }
| { key: string; type: "removed"; oldVal: string }
| { key: string; type: "changed"; oldVal: string; newVal: string };
const changes = editableKeys.flatMap<Change>((key) => {
const wasSet = key in initialValues;
const isSet = key in newValues;
const oldVal = initialValues[key];
const newVal = newValues[key];
if (!wasSet && !isSet) return [];
if (wasSet && isSet && stableStringify(oldVal) === stableStringify(newVal)) return [];
if (!wasSet && isSet) {
return [{ key, type: "added" as const, newVal: String(newVal) }];
}
if (wasSet && !isSet) {
return [{ key, type: "removed" as const, oldVal: String(oldVal) }];
}
return [
{
key,
type: "changed" as const,
oldVal: String(oldVal),
newVal: String(newVal),
},
];
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>Confirm feature flag changes</DialogHeader>
<DialogDescription>
These changes affect all organizations globally. Please review carefully.
</DialogDescription>
<div className="flex flex-col gap-2 pb-2">
{changes.length === 0 ? (
<p className="text-sm text-text-dimmed">No changes to apply.</p>
) : (
changes.map((change) => (
<div
key={change.key}
className="rounded-md border border-charcoal-600 bg-charcoal-800 px-3 py-2 font-mono text-xs"
>
<div className="font-sans text-sm text-text-bright">{change.key}</div>
{change.type === "added" && (
<div className="mt-1 text-green-400">+ {change.newVal}</div>
)}
{change.type === "removed" && (
<div className="mt-1 text-red-400">- {change.oldVal} (unset)</div>
)}
{change.type === "changed" && (
<>
<div className="mt-1 text-red-400">- {change.oldVal}</div>
<div className="text-green-400">+ {change.newVal}</div>
</>
)}
</div>
))
)}
</div>
<DialogFooter>
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="danger/small"
onClick={onConfirm}
disabled={isSaving || changes.length === 0}
>
{isSaving ? "Saving..." : "Apply changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+36 -12
View File
@@ -2,7 +2,9 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { useState } from "react";
import { z } from "zod";
import { FeatureFlagsDialog } from "~/components/admin/FeatureFlagsDialog";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { Input } from "~/components/primitives/Input";
@@ -46,6 +48,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export default function AdminDashboardRoute() {
const { organizations, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
const [flagsOrgId, setFlagsOrgId] = useState<string | null>(null);
const [flagsOpen, setFlagsOpen] = useState(false);
const flagsOrgTitle = organizations.find((o) => o.id === flagsOrgId)?.title ?? "";
const openFlagsDialog = (orgId: string) => {
setFlagsOrgId(orgId);
setFlagsOpen(true);
};
return (
<main
aria-labelledby="primary-heading"
@@ -113,18 +124,25 @@ export default function AdminDashboardRoute() {
<TableCell>{org.v3Enabled ? "✅" : ""}</TableCell>
<TableCell>{org.deletedAt ? "☠️" : ""}</TableCell>
<TableCell isSticky={true}>
<LinkButton
to={`/@/orgs/${org.slug}`}
className="mr-2"
variant="tertiary/small"
shortcut={
organizations.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</LinkButton>
<div className="flex items-center gap-2">
<Button
variant="tertiary/small"
onClick={() => openFlagsDialog(org.id)}
>
Flags
</Button>
<LinkButton
to={`/@/orgs/${org.slug}`}
variant="tertiary/small"
shortcut={
organizations.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</LinkButton>
</div>
</TableCell>
</TableRow>
);
@@ -135,6 +153,12 @@ export default function AdminDashboardRoute() {
<PaginationControls currentPage={page} totalPages={pageCount} />
</div>
<FeatureFlagsDialog
orgId={flagsOrgId}
orgTitle={flagsOrgTitle}
open={flagsOpen}
onOpenChange={setFlagsOpen}
/>
</main>
);
}
+4
View File
@@ -36,6 +36,10 @@ export default function Page() {
label: "LLM Models",
to: "/admin/llm-models",
},
{
label: "Feature Flags",
to: "/admin/feature-flags",
},
{
label: "Notifications",
to: "/admin/notifications",
@@ -2,7 +2,7 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { requireUser } from "~/services/session.server";
import { prisma } from "~/db.server";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags";
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
async function hasLogsPageAccess(
@@ -8,7 +8,8 @@ import parseDuration from "parse-duration";
import { z } from "zod";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { logger } from "../logger.server";
import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server";
+2 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
export async function canAccessAi(options: {
userId: string;
@@ -1,6 +1,7 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
export async function canAccessAiModels(options: {
userId: string;
@@ -1,6 +1,7 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
export async function canAccessPrivateConnections(options: {
organizationSlug: string;
+2 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
export async function canAccessQuery(options: {
userId: string;
@@ -7,7 +7,8 @@ import {
import { IEventRepository, TraceEventOptions } from "./eventRepository.types";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG, flag } from "../featureFlags.server";
import { FEATURE_FLAG } from "../featureFlags";
import { flag } from "../featureFlags.server";
import { getTaskEventStore } from "../taskEventStore.server";
export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository {
+7 -50
View File
@@ -1,31 +1,10 @@
import { z } from "zod";
import { type z } from "zod";
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
export const FEATURE_FLAG = {
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
runsListRepository: "runsListRepository",
taskEventRepository: "taskEventRepository",
hasQueryAccess: "hasQueryAccess",
hasLogsPageAccess: "hasLogsPageAccess",
hasAiAccess: "hasAiAccess",
hasAiModelsAccess: "hasAiModelsAccess",
hasComputeAccess: "hasComputeAccess",
hasPrivateConnections: "hasPrivateConnections",
} as const;
const FeatureFlagCatalog = {
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(),
[FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]),
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]),
[FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiModelsAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
};
type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
import {
type FeatureFlagKey,
FeatureFlagCatalog,
FeatureFlagCatalogSchema,
} from "~/v3/featureFlags";
export type FlagsOptions<T extends FeatureFlagKey> = {
key: T;
@@ -43,7 +22,7 @@ export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {
async function flag<T extends FeatureFlagKey>(
opts: FlagsOptions<T>
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined> {
const value = await _prisma.featureFlag.findUnique({
const value = await _prisma.featureFlag.findFirst({
where: {
key: opts.key,
},
@@ -146,28 +125,6 @@ export const flag = makeFlag();
export const flags = makeFlags();
export const setFlag = makeSetFlag();
// Create a Zod schema from the existing catalog
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
export type FeatureFlagCatalog = z.infer<typeof FeatureFlagCatalogSchema>;
// Utility function to validate a feature flag value
export function validateFeatureFlagValue<T extends FeatureFlagKey>(
key: T,
value: unknown
): z.SafeParseReturnType<unknown, z.infer<(typeof FeatureFlagCatalog)[T]>> {
return FeatureFlagCatalog[key].safeParse(value);
}
// Utility function to validate all feature flags at once
export function validateAllFeatureFlags(values: Record<string, unknown>) {
return FeatureFlagCatalogSchema.safeParse(values);
}
// Utility function to validate partial feature flags (all keys optional)
export function validatePartialFeatureFlags(values: Record<string, unknown>) {
return FeatureFlagCatalogSchema.partial().safeParse(values);
}
// Utility function to set multiple feature flags at once
export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma) {
return async function setMultipleFlags(
+93
View File
@@ -0,0 +1,93 @@
import { z } from "zod";
export const FEATURE_FLAG = {
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
runsListRepository: "runsListRepository",
taskEventRepository: "taskEventRepository",
hasQueryAccess: "hasQueryAccess",
hasLogsPageAccess: "hasLogsPageAccess",
hasAiAccess: "hasAiAccess",
hasAiModelsAccess: "hasAiModelsAccess",
hasComputeAccess: "hasComputeAccess",
hasPrivateConnections: "hasPrivateConnections",
} as const;
export const FeatureFlagCatalog = {
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(),
[FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]),
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]),
[FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiModelsAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
};
export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
// Infrastructure flags that are read-only on the global flags page.
// Shown with current/resolved value but no controls.
export const GLOBAL_LOCKED_FLAGS: FeatureFlagKey[] = [
FEATURE_FLAG.defaultWorkerInstanceGroupId,
FEATURE_FLAG.runsListRepository,
FEATURE_FLAG.taskEventRepository,
];
// Flags that are read-only on the org-level dialog.
// Shown with global value but no controls (org can't override these).
export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
FEATURE_FLAG.defaultWorkerInstanceGroupId,
FEATURE_FLAG.runsListRepository,
FEATURE_FLAG.taskEventRepository,
];
// Create a Zod schema from the existing catalog
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
export type FeatureFlagCatalog = z.infer<typeof FeatureFlagCatalogSchema>;
// Utility function to validate a feature flag value
export function validateFeatureFlagValue<T extends FeatureFlagKey>(
key: T,
value: unknown
): z.SafeParseReturnType<unknown, z.infer<(typeof FeatureFlagCatalog)[T]>> {
return FeatureFlagCatalog[key].safeParse(value);
}
// Utility function to validate all feature flags at once
export function validateAllFeatureFlags(values: Record<string, unknown>) {
return FeatureFlagCatalogSchema.safeParse(values);
}
// Utility function to validate partial feature flags (all keys optional)
export function validatePartialFeatureFlags(values: Record<string, unknown>) {
return FeatureFlagCatalogSchema.partial().safeParse(values);
}
// Utility types for catalog-driven UI rendering
export type FlagControlType =
| { type: "boolean" }
| { type: "enum"; options: string[] }
| { type: "string" };
export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType {
const typeName = schema._def.typeName;
if (typeName === "ZodBoolean") {
return { type: "boolean" };
}
if (typeName === "ZodEnum") {
return { type: "enum", options: schema._def.values as string[] };
}
return { type: "string" };
}
export function getAllFlagControlTypes(): Record<string, FlagControlType> {
const result: Record<string, FlagControlType> = {};
for (const [key, schema] of Object.entries(FeatureFlagCatalog)) {
result[key] = getFlagControlType(schema);
}
return result;
}
@@ -3,7 +3,8 @@ import { machinePresetFromName } from "~/v3/machinePresets.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import type { PrismaClientOrTransaction } from "~/db.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { ServiceValidationError } from "./baseService.server";
import { FailDeploymentService } from "./failDeployment.server";
@@ -2,7 +2,8 @@ import { WorkerInstanceGroup, WorkerInstanceGroupType } from "@trigger.dev/datab
import { WithRunEngine } from "../baseService.server";
import { WorkerGroupTokenService } from "./workerGroupTokenService.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG, makeFlag, makeSetFlag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag, makeSetFlag } from "~/v3/featureFlags.server";
export class WorkerGroupService extends WithRunEngine {
private readonly defaultNamePrefix = "worker_group";
+1
View File
@@ -162,6 +162,7 @@
"ioredis": "^5.3.2",
"isbot": "^3.6.5",
"jose": "^5.4.0",
"json-stable-stringify": "^1.3.0",
"jsonpointer": "^5.0.1",
"lodash.omit": "^4.5.0",
"lucide-react": "^0.229.0",
+9 -6
View File
@@ -629,6 +629,9 @@ importers:
jose:
specifier: ^5.4.0
version: 5.4.0
json-stable-stringify:
specifier: ^1.3.0
version: 1.3.0
jsonpointer:
specifier: ^5.0.1
version: 5.0.1
@@ -1131,7 +1134,7 @@ importers:
version: 18.3.1
react-email:
specifier: ^2.1.1
version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0)
version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0)
resend:
specifier: ^3.2.0
version: 3.2.0
@@ -39434,7 +39437,7 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0):
react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0):
dependencies:
'@babel/parser': 7.24.1
'@radix-ui/colors': 1.0.1
@@ -39471,8 +39474,8 @@ snapshots:
react: 18.3.1
react-dom: 18.2.0(react@18.3.1)
shelljs: 0.8.5
socket.io: 4.7.3
socket.io-client: 4.7.3
socket.io: 4.7.3(bufferutil@4.0.9)
socket.io-client: 4.7.3(bufferutil@4.0.9)
sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1)
source-map-js: 1.0.2
stacktrace-parser: 0.1.10
@@ -40699,7 +40702,7 @@ snapshots:
- supports-color
- utf-8-validate
socket.io-client@4.7.3:
socket.io-client@4.7.3(bufferutil@4.0.9):
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.7(supports-color@10.0.0)
@@ -40728,7 +40731,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
socket.io@4.7.3:
socket.io@4.7.3(bufferutil@4.0.9):
dependencies:
accepts: 1.3.8
base64id: 2.0.0