authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-13 10:43:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 12:43:56-05:00
log8ac4b97dae6bff7258fa5091a908116902fbd635
treecbd33a3162532043054bc76bf0789d4712ed37d4
parent4292720fd927f4c81c21da4e596bdfcedbde32ce
signature Signed by PGP key B5690EEEBB952194

fix(web): settings warn when an environment silently drops a setting (#18334)


2 files changed, 191 insertions(+), 30 deletions(-)

apps/web/src/components/settings/scopedSettings.test.ts+93-1
......@@ -230,6 +230,89 @@ describe("scoped settings writes", () => {
230230 expect(persistClient).not.toHaveBeenCalled();
231231 });
232232
233 describe("an environment that saves but drops keys it does not know", () => {
234 // Laptop knows every key; Server is an older build that keeps its settings as they were.
235 const upgradedLaptop = (saved: Partial<ServerSettings>) =>
236 vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) => ({
237 _tag: "Success" as const,
238 value: {
239 ...DEFAULT_SERVER_SETTINGS,
240 ...(environmentId === laptop.environmentId ? saved : {}),
241 },
242 }));
243 const outcome = async (
244 plan: ReturnType<typeof planScopedSettingsPatch>,
245 persistServer: ReturnType<typeof upgradedLaptop>,
246 ) => {
247 const result = await persistScopedSettingsPatch(plan, persistServer, vi.fn());
248 return {
249 saved: result.savedEnvironments.map(({ label }) => label),
250 failed: result.failedEnvironments.map(({ label, message }) => ({ label, message })),
251 };
252 };
253 const unsupported = [
254 {
255 label: server.label,
256 message: "This environment does not support this setting. Update T3 Code on it.",
257 },
258 ];
259
260 it("reports a dropped toggle", async () => {
261 expect(
262 await outcome(
263 planScopedSettingsPatch(all, environments, { reviewGuidesEnabled: true }),
264 upgradedLaptop({ reviewGuidesEnabled: true }),
265 ),
266 ).toEqual({ saved: [laptop.label], failed: unsupported });
267 });
268
269 it("reports dropped text but accepts the server trimming it", async () => {
270 expect(
271 await outcome(
272 planScopedSettingsPatch(all, environments, { reviewGuideInstructions: " Read tests " }),
273 upgradedLaptop({ reviewGuideInstructions: "Read tests" }),
274 ),
275 ).toEqual({ saved: [laptop.label], failed: unsupported });
276 });
277
278 it("reports a dropped key inside a project override", async () => {
279 expect(
280 await outcome(
281 planScopedSettingsPatch(project, environments, { defaultAutoPull: true }),
282 upgradedLaptop({
283 projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } },
284 }),
285 ),
286 ).toEqual({ saved: [laptop.label], failed: unsupported });
287 });
288
289 it("reports a nested key dropped beside one that saved", async () => {
290 const patch = { defaultAutoPull: true, enableAgentBrowserAccess: false };
291 const persistServer = vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) => ({
292 _tag: "Success" as const,
293 value: {
294 ...DEFAULT_SERVER_SETTINGS,
295 projectSettingsOverrides:
296 environmentId === laptop.environmentId
297 ? { [laptopProjectId]: patch }
298 : { [projectId]: { defaultAutoPull: true } },
299 },
300 }));
301 expect(
302 await outcome(planScopedSettingsPatch(project, environments, patch), persistServer),
303 ).toEqual({ saved: [laptop.label], failed: unsupported });
304 });
305
306 it("accepts a write that matches what the environment already had", async () => {
307 expect(
308 await outcome(
309 planScopedSettingsPatch(all, environments, { reviewGuidesEnabled: false }),
310 upgradedLaptop({}),
311 ),
312 ).toEqual({ saved: [laptop.label, server.label], failed: [] });
313 });
314 });
315
233316 it("persists client keys locally at any scope alongside server keys", async () => {
234317 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
235318 const persistClient = vi.fn();
......@@ -262,6 +345,7 @@ describe("scoped settings writes", () => {
262345 {
263346 environmentId: server.environmentId,
264347 label: server.label,
348 previous: expect.anything(),
265349 patch: {
266350 projectSettingsOverrides: {
267351 [projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true },
......@@ -271,6 +355,7 @@ describe("scoped settings writes", () => {
271355 {
272356 environmentId: laptop.environmentId,
273357 label: laptop.label,
358 previous: expect.anything(),
274359 patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } },
275360 },
276361 ]);
......@@ -391,6 +476,7 @@ describe("scoped settings writes", () => {
391476 {
392477 environmentId: server.environmentId,
393478 label: server.label,
479 previous: expect.anything(),
394480 patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } },
395481 },
396482 ]);
......@@ -527,6 +613,7 @@ describe("project overrides at environment scope", () => {
527613 {
528614 environmentId: laptop,
529615 label: laptop,
616 previous: expect.anything(),
530617 patch: {
531618 projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: "local" }, [t3]: null },
532619 },
......@@ -655,6 +742,11 @@ it("routes legacy model preference keys exclusively to the selected environment"
655742 expect(plan.clientPatch).toEqual({});
656743 expect(plan.hasClientWrite).toBe(false);
657744 expect(plan.serverWrites).toEqual([
658 { environmentId: server.environmentId, label: server.label, patch },
745 {
746 environmentId: server.environmentId,
747 label: server.label,
748 previous: expect.anything(),
749 patch,
750 },
659751 ]);
660752});
apps/web/src/components/settings/scopedSettings.ts+98-29
......@@ -166,6 +166,8 @@ interface ScopedServerWrite {
166166 readonly environmentId: EnvironmentId;
167167 readonly label: string;
168168 readonly patch: ServerSettingsPatch;
169 /** The environment's settings before the write, to tell a dropped key from a saved one. */
170 readonly previous: ServerSettings;
169171}
170172
171173function projectOverrideWrites(
......@@ -194,6 +196,7 @@ function projectOverrideWrites(
194196 writes.set(member.environmentId, {
195197 environmentId: member.environmentId,
196198 label: environment.label,
199 previous: settings,
197200 patch: {
198201 projectSettingsOverrides: {
199202 ...existing?.patch.projectSettingsOverrides,
......@@ -266,29 +269,38 @@ export function planScopedSettingsPatch(
266269 return next as ProjectSettingsOverrides;
267270 })
268271 : scope.kind === "all" || scope.kind === "environment"
269 ? connectedEnvironments.map((environment) => ({
270 environmentId: environment.environmentId,
271 label: environment.label,
272 patch:
273 environment.serverConfig?.settings.worktreeCleanup != null &&
274 serverPatch.storageCleanup &&
275 Object.keys(serverPatch.storageCleanup).some((key) => key.startsWith("worktree"))
276 ? {
277 ...serverPatch,
278 worktreeCleanup: {
279 mode: "custom",
280 rules: {
281 ...resolveWorktreeCleanup(environment.serverConfig.settings, null),
282 ...Object.fromEntries(
283 Object.entries(serverPatch.storageCleanup).filter(([key]) =>
284 key.startsWith("worktree"),
285 ),
286 ),
287 },
288 },
289 }
290 : serverPatch,
291 }))
272 ? connectedEnvironments.flatMap((environment) => {
273 const previous = environment.serverConfig?.settings;
274 if (!previous) return [];
275 return [
276 {
277 environmentId: environment.environmentId,
278 label: environment.label,
279 previous,
280 patch:
281 previous.worktreeCleanup != null &&
282 serverPatch.storageCleanup &&
283 Object.keys(serverPatch.storageCleanup).some((key) =>
284 key.startsWith("worktree"),
285 )
286 ? {
287 ...serverPatch,
288 worktreeCleanup: {
289 mode: "custom",
290 rules: {
291 ...resolveWorktreeCleanup(previous, null),
292 ...Object.fromEntries(
293 Object.entries(serverPatch.storageCleanup).filter(([key]) =>
294 key.startsWith("worktree"),
295 ),
296 ),
297 },
298 },
299 }
300 : serverPatch,
301 },
302 ];
303 })
292304 : [];
293305 const hasClientWrite = Object.keys(clientPatch).length > 0;
294306 const hasWrite = hasClientWrite || serverWrites.length > 0;
......@@ -371,6 +383,7 @@ export function planProjectOverridesClear(
371383 writes.set(environmentId, {
372384 environmentId,
373385 label: environment.label,
386 previous: settings,
374387 patch: {
375388 projectSettingsOverrides: {
376389 ...existing?.patch.projectSettingsOverrides,
......@@ -389,13 +402,56 @@ export function planProjectOverridesClear(
389402 };
390403}
391404
405/**
406 * Whether a server kept `before` where `value` asked for a change. Objects are
407 * compared field by field, so a nested key an older server dropped is caught
408 * even when a sibling field saved. A null clears the field; strings may come
409 * back trimmed.
410 */
411function writeWasDropped(value: unknown, before: unknown, saved: unknown): boolean {
412 if (value === null) return before != null && Equal.equals(saved, before);
413 if (isPlainObject(value) && !Array.isArray(before) && !Array.isArray(saved)) {
414 const field = (source: unknown, key: string) =>
415 isPlainObject(source) ? source[key] : undefined;
416 return Object.entries(value).some(([key, next]) =>
417 writeWasDropped(next, field(before, key), field(saved, key)),
418 );
419 }
420 const unchanged =
421 typeof value === "string" && typeof before === "string"
422 ? value.trim() === before
423 : Equal.equals(value, before);
424 return !unchanged && Equal.equals(saved, before);
425}
426
427/**
428 * An older server drops keys it does not know, top-level or nested, and still
429 * succeeds. Left unreported, the row stays mixed forever.
430 */
431function droppedSettingKeys(write: ScopedServerWrite, saved: ServerSettings | undefined) {
432 if (saved === undefined) return [];
433 return Object.entries(write.patch).flatMap(([key, value]) =>
434 writeWasDropped(
435 value,
436 write.previous[key as keyof ServerSettings],
437 saved[key as keyof ServerSettings],
438 )
439 ? [key]
440 : [],
441 );
442}
443
392444/** Wait for every target so a failed environment does not hide successful or later writes. */
393445export async function persistScopedSettingsPatch(
394446 plan: ReturnType<typeof planScopedSettingsPatch>,
395447 persistServer: (input: {
396448 environmentId: EnvironmentId;
397449 input: { patch: ServerSettingsPatch };
398 }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>,
450 }) => Promise<{
451 readonly _tag: "Success" | "Failure";
452 readonly cause?: Cause.Cause<unknown>;
453 readonly value?: ServerSettings;
454 }>,
399455 persistClient: (patch: ClientSettingsPatch) => void,
400456) {
401457 if (plan.hasClientWrite) persistClient(plan.clientPatch);
......@@ -404,9 +460,25 @@ export async function persistScopedSettingsPatch(
404460 persistServer({ environmentId, input: { patch } }),
405461 ),
406462 );
463 const saved = (index: number) => {
464 const result = results[index];
465 return (
466 result?.status === "fulfilled" &&
467 result.value._tag === "Success" &&
468 droppedSettingKeys(plan.serverWrites[index]!, result.value.value).length === 0
469 );
470 };
407471 const failedEnvironments = plan.serverWrites.flatMap((environment, index) => {
472 if (saved(index)) return [];
408473 const result = results[index];
409 if (result?.status === "fulfilled" && result.value._tag === "Success") return [];
474 if (result?.status === "fulfilled" && result.value._tag === "Success") {
475 return [
476 {
477 ...environment,
478 message: "This environment does not support this setting. Update T3 Code on it.",
479 },
480 ];
481 }
410482 const error: unknown =
411483 result?.status === "rejected"
412484 ? result.reason
......@@ -423,10 +495,7 @@ export async function persistScopedSettingsPatch(
423495 },
424496 ];
425497 });
426 const savedEnvironments = plan.serverWrites.filter((_, index) => {
427 const result = results[index];
428 return result?.status === "fulfilled" && result.value._tag === "Success";
429 });
498 const savedEnvironments = plan.serverWrites.filter((_, index) => saved(index));
430499 return {
431500 failedEnvironments,
432501 savedEnvironments,