Skip to content

Commit a7f0f90

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
fix(webapp): show stored org values for locked flags in the admin flags dialog
The admin org flags dialog now shows an org's own value for read-only flags, and says what that value does: it overrides the global value, it's stored but ignored, or it's a server-written stamp. Stored org flags are now read one key at a time, so a single invalid value no longer makes the dialog and the admin API treat the org as having no flags. Saves keep stored values they didn't change, including keys the running version doesn't know yet. Mono-RevId: 061e76440f3462887e0b63d75b556a70ae02c480
1 parent 3cacfa4 commit a7f0f90

6 files changed

Lines changed: 673 additions & 67 deletions

‎apps/webapp/app/components/admin/FeatureFlagsDialog.tsx‎

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ import { Callout } from "~/components/primitives/Callout";
1313
import { LockClosedIcon } from "@heroicons/react/20/solid";
1414
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
1515
import { cn } from "~/utils/cn";
16-
import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags";
16+
import {
17+
FEATURE_FLAG,
18+
ORG_LOCKED_FLAG_EFFECT,
19+
ORG_LOCKED_FLAGS,
20+
isUnchangedInvalidFlag,
21+
type FeatureFlagKey,
22+
type FlagControlType,
23+
type OrgLockedFlagEffect,
24+
} from "~/v3/featureFlags";
1725
import {
1826
UNSET_VALUE,
1927
BooleanControl,
@@ -33,6 +41,7 @@ import {
3341
type LoaderData = {
3442
org: { id: string; title: string; slug: string };
3543
orgFlags: Record<string, unknown>;
44+
invalidOrgFlags?: Record<string, unknown>;
3645
globalFlags: Record<string, unknown>;
3746
controlTypes: Record<string, FlagControlType>;
3847
workerGroupName?: string;
@@ -41,6 +50,22 @@ type LoaderData = {
4150
isManagedCloud?: boolean;
4251
};
4352

53+
const LOCKED_EFFECT_LABEL: Record<OrgLockedFlagEffect, string> = {
54+
live: "overrides global",
55+
ignored: "stored, ignored",
56+
derived: "grace stamp",
57+
};
58+
59+
// Most flags fall back to global when the org value fails its schema, but not all of them.
60+
const INVALID_VALUE_LABEL: Partial<Record<string, string>> = {
61+
[FEATURE_FLAG.dashboardAgentTurnEvalsEnabled]: "invalid, treated as off",
62+
[FEATURE_FLAG.workerQueueEnvironmentOverrides]: "invalid, valid entries still apply",
63+
};
64+
65+
function formatStoredValue(value: unknown): string {
66+
return typeof value === "string" ? value : JSON.stringify(value);
67+
}
68+
4469
type ActionData = {
4570
success?: boolean;
4671
error?: string;
@@ -85,7 +110,8 @@ export function FeatureFlagsDialog({
85110

86111
useEffect(() => {
87112
if (loadFetcher.data) {
88-
const loaded = loadFetcher.data.orgFlags ?? {};
113+
// Invalid stored values ride along unchanged, so a save keeps them unless unset.
114+
const loaded = { ...loadFetcher.data.orgFlags, ...loadFetcher.data.invalidOrgFlags };
89115
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
90116
setOverrides({ ...loaded });
91117
setInitialOverrides({ ...loaded });
@@ -102,8 +128,16 @@ export function FeatureFlagsDialog({
102128
}, [saveFetcher.data]);
103129

104130
const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides);
105-
const environmentOverrides = (overrides[FEATURE_FLAG.workerQueueEnvironmentOverrides] ??
106-
{}) as WorkerQueueEnvironmentOverrides;
131+
// An untouched invalid stored value isn't a draft: it renders as an invalid row, not the editor.
132+
const environmentOverrides = (
133+
isUnchangedInvalidFlag(
134+
FEATURE_FLAG.workerQueueEnvironmentOverrides,
135+
overrides,
136+
loadFetcher.data?.invalidOrgFlags
137+
)
138+
? {}
139+
: (overrides[FEATURE_FLAG.workerQueueEnvironmentOverrides] ?? {})
140+
) as WorkerQueueEnvironmentOverrides;
107141
const hasIncompleteOverrides = incompleteOverrideScopes(environmentOverrides).length > 0;
108142

109143
const setFlagValue = (key: string, value: unknown) => {
@@ -179,18 +213,86 @@ export function FeatureFlagsDialog({
179213
? String(globalValue)
180214
: "unset";
181215

216+
const effect = ORG_LOCKED_FLAG_EFFECT[key as FeatureFlagKey];
217+
const invalidStored = data.invalidOrgFlags?.[key];
218+
const isInvalidStored = isUnchangedInvalidFlag(
219+
key,
220+
overrides,
221+
data.invalidOrgFlags
222+
);
223+
224+
if (isInvalidStored) {
225+
return (
226+
<div
227+
key={key}
228+
className="flex items-center justify-between rounded-md border border-error/30 bg-error/5 px-3 py-2.5"
229+
>
230+
<div className="min-w-0 flex-1">
231+
<div className="truncate text-sm text-text-bright">{key}</div>
232+
<div className="truncate text-xs text-text-dimmed">
233+
org: {formatStoredValue(invalidStored)} (
234+
{INVALID_VALUE_LABEL[key] ?? "invalid"}) · global: {globalDisplay}
235+
</div>
236+
</div>
237+
{locked && effect === "derived" ? (
238+
<LockClosedIcon className="size-4 text-text-faint" />
239+
) : (
240+
<Button variant="minimal/small" onClick={() => unsetFlag(key)}>
241+
unset
242+
</Button>
243+
)}
244+
</div>
245+
);
246+
}
247+
182248
if (locked) {
249+
const hasOrgValue = key in overrides;
250+
const orgValue = overrides[key];
251+
const orgWorkerGroup =
252+
isWorkerGroup && hasOrgValue
253+
? data.workerGroups?.find((group) => group.id === orgValue)
254+
: undefined;
255+
const orgDisplay = orgWorkerGroup
256+
? `${orgWorkerGroup.name} (${String(orgValue).slice(0, 8)}...)`
257+
: formatStoredValue(orgValue);
258+
const canUnset = hasOrgValue && effect !== "derived";
259+
183260
return (
184261
<div
185262
key={key}
186-
className="flex items-center justify-between rounded-md border border-transparent bg-background-hover px-3 py-2.5"
263+
className={cn(
264+
"flex items-center justify-between rounded-md border px-3 py-2.5",
265+
hasOrgValue
266+
? "border-indigo-500/20 bg-indigo-500/5"
267+
: "border-transparent bg-background-hover"
268+
)}
187269
title="Global-level setting - not editable per org"
188270
>
189271
<div className="min-w-0 flex-1">
190-
<div className="truncate text-sm text-text-dimmed">{key}</div>
191-
<div className="text-xs text-text-dimmed">global: {globalDisplay}</div>
272+
<div
273+
className={cn(
274+
"truncate text-sm",
275+
hasOrgValue ? "text-text-bright" : "text-text-dimmed"
276+
)}
277+
>
278+
{key}
279+
</div>
280+
{hasOrgValue ? (
281+
<div className="truncate text-xs text-text-dimmed">
282+
org: {orgDisplay} ({effect ? LOCKED_EFFECT_LABEL[effect] : "stored"}) ·
283+
global: {globalDisplay}
284+
</div>
285+
) : (
286+
<div className="text-xs text-text-dimmed">global: {globalDisplay}</div>
287+
)}
192288
</div>
193-
<LockClosedIcon className="size-4 text-text-faint" />
289+
{canUnset ? (
290+
<Button variant="minimal/small" onClick={() => unsetFlag(key)}>
291+
unset
292+
</Button>
293+
) : (
294+
<LockClosedIcon className="size-4 text-text-faint" />
295+
)}
194296
</div>
195297
);
196298
}

‎apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts‎

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { prisma } from "~/db.server";
77
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
88
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
99
import { selectMintBaselineSource, stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
10-
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
10+
import {
11+
isPlainFlagObject,
12+
parseStoredOrgFlags,
13+
validatePartialFeatureFlags,
14+
} from "~/v3/featureFlags";
1115
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
1216

1317
const ParamsSchema = z.object({
@@ -34,11 +38,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3438
return json({ error: "Organization not found" }, { status: 404 });
3539
}
3640

37-
const flagsResult = organization.featureFlags
38-
? validatePartialFeatureFlags(organization.featureFlags as Record<string, unknown>)
39-
: { success: false as const };
40-
41-
const featureFlags = flagsResult.success ? flagsResult.data : {};
41+
const featureFlags = parseStoredOrgFlags(organization.featureFlags).valid;
4242

4343
return json({
4444
organizationId: organization.id,
@@ -89,10 +89,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
8989
}
9090

9191
const existingRaw = rows[0].featureFlags as Record<string, unknown> | null;
92-
const existingResult = existingRaw
93-
? validatePartialFeatureFlags(existingRaw)
94-
: ({ success: false } as const);
95-
const existingData = existingResult.success ? existingResult.data : {};
92+
// Merge onto the raw object: keys this release doesn't know, or values that no longer
93+
// validate, belong to the org and must survive an unrelated write.
94+
const existingData = isPlainFlagObject(existingRaw) ? existingRaw : {};
9695

9796
// Stamp the flip from the control-plane DB clock so the grace-window cutover is anchored to
9897
// one authoritative time source, not whichever webapp process handled this request.
@@ -130,15 +129,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
130129
// Org feature flags are embedded in every env of the org; drop all its cached env rows.
131130
controlPlaneResolver.invalidateOrganization(organizationId);
132131

133-
const updatedFlagsResult = updatedOrganization.featureFlags
134-
? validatePartialFeatureFlags(updatedOrganization.featureFlags as Record<string, unknown>)
135-
: { success: false as const };
136-
137132
return json({
138133
success: true,
139134
organizationId: updatedOrganization.id,
140135
organizationSlug: updatedOrganization.slug,
141-
featureFlags: updatedFlagsResult.success ? updatedFlagsResult.data : {},
136+
featureFlags: parseStoredOrgFlags(updatedOrganization.featureFlags).valid,
142137
});
143138
} catch (error) {
144139
return json(

‎apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts‎

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import { selectMintBaselineSource, stampMintKindFlip } from "~/v3/runOpsMigratio
1010
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
1111
import {
1212
FEATURE_FLAG,
13-
validatePartialFeatureFlags,
1413
getAllFlagControlTypes,
14+
isPlainFlagObject,
15+
mergeReplacedOrgFlags,
16+
parseStoredOrgFlags,
1517
} from "~/v3/featureFlags";
1618
import { featuresForRequest } from "~/features.server";
1719

@@ -69,11 +71,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
6971
throw new Response("Organization not found", { status: 404 });
7072
}
7173

72-
const orgFlagsResult = organization.featureFlags
73-
? validatePartialFeatureFlags(organization.featureFlags as Record<string, unknown>)
74-
: ({ success: false } as const);
75-
76-
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
74+
const { valid: orgFlags, invalid: invalidOrgFlags } = parseStoredOrgFlags(
75+
organization.featureFlags
76+
);
7777
const controlTypes = getAllFlagControlTypes();
7878

7979
// Resolve worker group name for display
@@ -98,6 +98,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
9898
slug: organization.slug,
9999
},
100100
orgFlags,
101+
invalidOrgFlags,
101102
globalFlags,
102103
controlTypes,
103104
workerGroupName,
@@ -127,42 +128,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
127128
return json({ error: "Invalid JSON body" }, { status: 400 });
128129
}
129130

130-
if (
131-
body === null ||
132-
(typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0)
133-
) {
134-
// Clear all flags. No grace stamp (nothing to flip) and no read-then-write race.
135-
try {
136-
await prisma.organization.update({
137-
where: { id: organizationId },
138-
data: { featureFlags: Prisma.JsonNull },
139-
});
140-
} catch (e) {
141-
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
142-
throw new Response("Organization not found", { status: 404 });
143-
}
144-
throw e;
145-
}
146-
147-
controlPlaneResolver.invalidateOrganization(organizationId);
148-
return json({ success: true });
149-
}
150-
151-
const validationResult = validatePartialFeatureFlags(body as Record<string, unknown>);
152-
if (!validationResult.success) {
153-
return json(
154-
{ error: "Invalid feature flags", details: validationResult.error.issues },
155-
{ status: 400 }
156-
);
131+
// null (the dialog with nothing set) clears every known flag. Stored keys this release
132+
// doesn't know survive it, like any other save: the dialog never showed them.
133+
const requestedBody = body === null ? {} : body;
134+
if (!isPlainFlagObject(requestedBody)) {
135+
return json({ error: "Invalid feature flags" }, { status: 400 });
157136
}
158137

159-
// Derived grace-stamp fields are computed server-side; never trust them from the body.
160-
const {
161-
runOpsMintKindPrev: _ignoredPrev,
162-
runOpsMintKindFlippedAt: _ignoredFlippedAt,
163-
...requestedFlags
164-
} = validationResult.data;
165-
166138
// Seed the flip baseline from the current GLOBAL mint flags so an org's FIRST per-org override
167139
// is graced from the currently-effective global kind, not the hardcoded default "cuid".
168140
const globalFlags = (await getGlobalFlags()) as Record<string, unknown>;
@@ -174,11 +146,24 @@ export async function action({ request, params }: ActionFunctionArgs) {
174146
SELECT "featureFlags" FROM "Organization" WHERE "id" = ${organizationId} FOR UPDATE`;
175147

176148
if (rows.length === 0) {
177-
return false;
149+
return { status: "not_found" } as const;
178150
}
179151

180152
const existingRaw = rows[0].featureFlags as Record<string, unknown> | null;
181153

154+
// Validated against the stored row, so an unchanged invalid value can be sent back as is.
155+
const merged = mergeReplacedOrgFlags(requestedBody, existingRaw);
156+
if (!merged.success) {
157+
return { status: "rejected", rejectedKeys: merged.rejectedKeys } as const;
158+
}
159+
160+
// Derived grace-stamp fields are computed server-side; never trust them from the body.
161+
const {
162+
runOpsMintKindPrev: _ignoredPrev,
163+
runOpsMintKindFlippedAt: _ignoredFlippedAt,
164+
...requestedFlags
165+
} = merged.flags;
166+
182167
// Anchor the flip stamp to the control-plane DB clock (see the v1 route), not this process's.
183168
const [{ now: controlPlaneNow }] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
184169

@@ -191,16 +176,29 @@ export async function action({ request, params }: ActionFunctionArgs) {
191176

192177
await tx.organization.update({
193178
where: { id: organizationId },
194-
data: { featureFlags: stamped as Prisma.InputJsonValue },
179+
data: {
180+
featureFlags:
181+
Object.keys(stamped).length === 0 ? Prisma.JsonNull : (stamped as Prisma.InputJsonValue),
182+
},
195183
});
196184

197-
return true;
185+
return { status: "updated" } as const;
198186
});
199187

200-
if (!updated) {
188+
if (updated.status === "not_found") {
201189
throw new Response("Organization not found", { status: 404 });
202190
}
203191

192+
if (updated.status === "rejected") {
193+
return json(
194+
{
195+
error: `Invalid feature flags: ${updated.rejectedKeys.join(", ")}`,
196+
details: updated.rejectedKeys.map((key) => ({ path: [key], message: "Invalid value" })),
197+
},
198+
{ status: 400 }
199+
);
200+
}
201+
204202
// Org feature flags are embedded in every env of the org; drop all its cached env rows.
205203
controlPlaneResolver.invalidateOrganization(organizationId);
206204

0 commit comments

Comments
 (0)