Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion apps/web/src/components/settings/scopedSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,89 @@ describe("scoped settings writes", () => {
expect(persistClient).not.toHaveBeenCalled();
});

describe("an environment that saves but drops keys it does not know", () => {
// Laptop knows every key; Server is an older build that keeps its settings as they were.
const upgradedLaptop = (saved: Partial<ServerSettings>) =>
vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) => ({
_tag: "Success" as const,
value: {
...DEFAULT_SERVER_SETTINGS,
...(environmentId === laptop.environmentId ? saved : {}),
},
}));
const outcome = async (
plan: ReturnType<typeof planScopedSettingsPatch>,
persistServer: ReturnType<typeof upgradedLaptop>,
) => {
const result = await persistScopedSettingsPatch(plan, persistServer, vi.fn());
return {
saved: result.savedEnvironments.map(({ label }) => label),
failed: result.failedEnvironments.map(({ label, message }) => ({ label, message })),
};
};
const unsupported = [
{
label: server.label,
message: "This environment does not support this setting. Update T3 Code on it.",
},
];

it("reports a dropped toggle", async () => {
expect(
await outcome(
planScopedSettingsPatch(all, environments, { reviewGuidesEnabled: true }),
upgradedLaptop({ reviewGuidesEnabled: true }),
),
).toEqual({ saved: [laptop.label], failed: unsupported });
});

it("reports dropped text but accepts the server trimming it", async () => {
expect(
await outcome(
planScopedSettingsPatch(all, environments, { reviewGuideInstructions: " Read tests " }),
upgradedLaptop({ reviewGuideInstructions: "Read tests" }),
),
).toEqual({ saved: [laptop.label], failed: unsupported });
});

it("reports a dropped key inside a project override", async () => {
expect(
await outcome(
planScopedSettingsPatch(project, environments, { defaultAutoPull: true }),
upgradedLaptop({
projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } },
}),
),
).toEqual({ saved: [laptop.label], failed: unsupported });
});

it("reports a nested key dropped beside one that saved", async () => {
const patch = { defaultAutoPull: true, enableAgentBrowserAccess: false };
const persistServer = vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) => ({
_tag: "Success" as const,
value: {
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides:
environmentId === laptop.environmentId
? { [laptopProjectId]: patch }
: { [projectId]: { defaultAutoPull: true } },
},
}));
expect(
await outcome(planScopedSettingsPatch(project, environments, patch), persistServer),
).toEqual({ saved: [laptop.label], failed: unsupported });
});

it("accepts a write that matches what the environment already had", async () => {
expect(
await outcome(
planScopedSettingsPatch(all, environments, { reviewGuidesEnabled: false }),
upgradedLaptop({}),
),
).toEqual({ saved: [laptop.label, server.label], failed: [] });
});
});

it("persists client keys locally at any scope alongside server keys", async () => {
const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
const persistClient = vi.fn();
Expand Down Expand Up @@ -262,6 +345,7 @@ describe("scoped settings writes", () => {
{
environmentId: server.environmentId,
label: server.label,
previous: expect.anything(),
patch: {
projectSettingsOverrides: {
[projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true },
Expand All @@ -271,6 +355,7 @@ describe("scoped settings writes", () => {
{
environmentId: laptop.environmentId,
label: laptop.label,
previous: expect.anything(),
patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } },
},
]);
Expand Down Expand Up @@ -391,6 +476,7 @@ describe("scoped settings writes", () => {
{
environmentId: server.environmentId,
label: server.label,
previous: expect.anything(),
patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } },
},
]);
Expand Down Expand Up @@ -527,6 +613,7 @@ describe("project overrides at environment scope", () => {
{
environmentId: laptop,
label: laptop,
previous: expect.anything(),
patch: {
projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: "local" }, [t3]: null },
},
Expand Down Expand Up @@ -655,6 +742,11 @@ it("routes legacy model preference keys exclusively to the selected environment"
expect(plan.clientPatch).toEqual({});
expect(plan.hasClientWrite).toBe(false);
expect(plan.serverWrites).toEqual([
{ environmentId: server.environmentId, label: server.label, patch },
{
environmentId: server.environmentId,
label: server.label,
previous: expect.anything(),
patch,
},
]);
});
127 changes: 98 additions & 29 deletions apps/web/src/components/settings/scopedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ interface ScopedServerWrite {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly patch: ServerSettingsPatch;
/** The environment's settings before the write, to tell a dropped key from a saved one. */
readonly previous: ServerSettings;
}

function projectOverrideWrites(
Expand Down Expand Up @@ -194,6 +196,7 @@ function projectOverrideWrites(
writes.set(member.environmentId, {
environmentId: member.environmentId,
label: environment.label,
previous: settings,
patch: {
projectSettingsOverrides: {
...existing?.patch.projectSettingsOverrides,
Expand Down Expand Up @@ -266,29 +269,38 @@ export function planScopedSettingsPatch(
return next as ProjectSettingsOverrides;
})
: scope.kind === "all" || scope.kind === "environment"
? connectedEnvironments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
patch:
environment.serverConfig?.settings.worktreeCleanup != null &&
serverPatch.storageCleanup &&
Object.keys(serverPatch.storageCleanup).some((key) => key.startsWith("worktree"))
? {
...serverPatch,
worktreeCleanup: {
mode: "custom",
rules: {
...resolveWorktreeCleanup(environment.serverConfig.settings, null),
...Object.fromEntries(
Object.entries(serverPatch.storageCleanup).filter(([key]) =>
key.startsWith("worktree"),
),
),
},
},
}
: serverPatch,
}))
? connectedEnvironments.flatMap((environment) => {
const previous = environment.serverConfig?.settings;
if (!previous) return [];
return [
{
environmentId: environment.environmentId,
label: environment.label,
previous,
patch:
previous.worktreeCleanup != null &&
serverPatch.storageCleanup &&
Object.keys(serverPatch.storageCleanup).some((key) =>
key.startsWith("worktree"),
)
? {
...serverPatch,
worktreeCleanup: {
mode: "custom",
rules: {
...resolveWorktreeCleanup(previous, null),
...Object.fromEntries(
Object.entries(serverPatch.storageCleanup).filter(([key]) =>
key.startsWith("worktree"),
),
),
},
},
}
: serverPatch,
},
];
})
: [];
const hasClientWrite = Object.keys(clientPatch).length > 0;
const hasWrite = hasClientWrite || serverWrites.length > 0;
Expand Down Expand Up @@ -371,6 +383,7 @@ export function planProjectOverridesClear(
writes.set(environmentId, {
environmentId,
label: environment.label,
previous: settings,
patch: {
projectSettingsOverrides: {
...existing?.patch.projectSettingsOverrides,
Expand All @@ -389,13 +402,56 @@ export function planProjectOverridesClear(
};
}

/**
* Whether a server kept `before` where `value` asked for a change. Objects are
* compared field by field, so a nested key an older server dropped is caught
* even when a sibling field saved. A null clears the field; strings may come
* back trimmed.
*/
function writeWasDropped(value: unknown, before: unknown, saved: unknown): boolean {
if (value === null) return before != null && Equal.equals(saved, before);
if (isPlainObject(value) && !Array.isArray(before) && !Array.isArray(saved)) {
const field = (source: unknown, key: string) =>
isPlainObject(source) ? source[key] : undefined;
return Object.entries(value).some(([key, next]) =>
writeWasDropped(next, field(before, key), field(saved, key)),
);
}
const unchanged =
typeof value === "string" && typeof before === "string"
? value.trim() === before
: Equal.equals(value, before);
return !unchanged && Equal.equals(saved, before);
}

/**
* An older server drops keys it does not know, top-level or nested, and still
* succeeds. Left unreported, the row stays mixed forever.
*/
function droppedSettingKeys(write: ScopedServerWrite, saved: ServerSettings | undefined) {
if (saved === undefined) return [];
return Object.entries(write.patch).flatMap(([key, value]) =>
writeWasDropped(
value,
write.previous[key as keyof ServerSettings],
saved[key as keyof ServerSettings],
)
? [key]
: [],
);
}

/** Wait for every target so a failed environment does not hide successful or later writes. */
export async function persistScopedSettingsPatch(
plan: ReturnType<typeof planScopedSettingsPatch>,
persistServer: (input: {
environmentId: EnvironmentId;
input: { patch: ServerSettingsPatch };
}) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>,
}) => Promise<{
readonly _tag: "Success" | "Failure";
readonly cause?: Cause.Cause<unknown>;
readonly value?: ServerSettings;
}>,
persistClient: (patch: ClientSettingsPatch) => void,
) {
if (plan.hasClientWrite) persistClient(plan.clientPatch);
Expand All @@ -404,9 +460,25 @@ export async function persistScopedSettingsPatch(
persistServer({ environmentId, input: { patch } }),
),
);
const saved = (index: number) => {
const result = results[index];
return (
result?.status === "fulfilled" &&
result.value._tag === "Success" &&
droppedSettingKeys(plan.serverWrites[index]!, result.value.value).length === 0
);
};
const failedEnvironments = plan.serverWrites.flatMap((environment, index) => {
if (saved(index)) return [];
const result = results[index];
if (result?.status === "fulfilled" && result.value._tag === "Success") return [];
if (result?.status === "fulfilled" && result.value._tag === "Success") {
return [
{
...environment,
message: "This environment does not support this setting. Update T3 Code on it.",
},
];
}
const error: unknown =
result?.status === "rejected"
? result.reason
Expand All @@ -423,10 +495,7 @@ export async function persistScopedSettingsPatch(
},
];
});
const savedEnvironments = plan.serverWrites.filter((_, index) => {
const result = results[index];
return result?.status === "fulfilled" && result.value._tag === "Success";
});
const savedEnvironments = plan.serverWrites.filter((_, index) => saved(index));
return {
failedEnvironments,
savedEnvironments,
Expand Down
Loading