Skip to content
Open
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.attachmentUploads).toBe(true);
expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.settingsKeys).toContain("reviewGuidesEnabled");
expect(second.capabilities.requiredWorktreeBootstrap).toBe(true);
expect(second.capabilities.usagePriceOverrides).toBe(true);
expect(second.capabilities.threadActiveReorder).toBe(true);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
EnvironmentId,
ORCHESTRATION_PROTOCOL_VERSION,
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
ServerSettings,
type ExecutionEnvironmentDescriptor,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
Expand Down Expand Up @@ -257,6 +258,7 @@ export const make = Effect.gen(function* () {
serverResolvedCommandContext: true,
environmentIcon: true,
projectCloneTracking: true,
settingsKeys: Object.keys(ServerSettings.fields),
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverInstallation === null ? {} : { serverInstallation }),
...(serverSelfUpdate === "boot-service" || desktopAppUpdate
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/settings/SettingInheritance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ export function SettingInheritance({
.filter(Boolean)
.join(" · ");
const layers = settingInheritanceLayers(target, environment.serverConfig.settings, key);
const winningLayer = layers.find((layer) => layer.effective);
const unsupported = target.unsupportedSettingKeys?.has(key) === true;
const winningLayer = unsupported ? undefined : layers.find((layer) => layer.effective);
return [
{
id: `${target.environmentId}:${target.projectId ?? ""}`,
Expand All @@ -205,8 +206,10 @@ export function SettingInheritance({
overrides: environment.serverConfig.settings.projectSettingsOverrides,
machine: resolveEnvironmentMachineKind(environment.serverConfig),
layers,
unsupported,
winningLayer,
resolution: {
unsupported,
winner: winningLayer?.key,
layers: layers.map(({ key, value }) => [key, value]),
settings: keys.map((settingKey) => target.settings[settingKey]),
Expand Down Expand Up @@ -263,8 +266,12 @@ export function SettingInheritance({
{groups.map((members) => {
const first = members[0];
if (!first) return null;
const { id, target, machine, layers, winningLayer } = first;
const chain = (
const { id, target, machine, layers, unsupported, winningLayer } = first;
const chain = unsupported ? (
<p className="px-2 py-1 text-sm text-muted-foreground">
Not supported. Update T3 Code on this environment to use this setting.
</p>
) : (
<ol role="list" className="text-sm">
{layers.map((layer) => (
<li
Expand Down Expand Up @@ -329,7 +336,7 @@ export function SettingInheritance({
<span className="flex w-full min-w-0 flex-col">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-foreground tabular-nums">
{winningLayer?.value}
{unsupported ? "Not supported" : winningLayer?.value}
</span>
<span className="text-xs text-muted-foreground">
{winningLayer?.key === "environment"
Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/components/settings/SettingsScopeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { createContext, type ReactNode, useContext, useMemo } from "react";
import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFilesQueryState";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
import { droppedSettingKeysAtom, droppedSettingKeysFor } from "./droppedSettingKeys";
import {
resolveScopedSettingsTargets,
resolveUnsupportedSettingKeys,
selectScopedSettingsEnvironments,
} from "./scopedSettings";
import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
import { selectSingleEnvironmentScope } from "./settingsScopeAxis";

Expand Down Expand Up @@ -50,7 +55,22 @@ function useMemberProjectFiles(scope: ReturnType<typeof resolveSettingsScope>) {

function useResolvedSettingsScope(rawSearch: SettingsScopeSearch, singleEnvironment: boolean) {
const groups = useSettingsProjectGroups();
const { environments: availableEnvironments } = useEnvironments();
const { environments } = useEnvironments();
const dropped = useAtomValue(droppedSettingKeysAtom);
const availableEnvironments = useMemo(
() =>
environments.map((environment) => {
const descriptor = environment.serverConfig?.environment;
const unsupportedSettingKeys = resolveUnsupportedSettingKeys(
descriptor?.capabilities.settingsKeys,
droppedSettingKeysFor(dropped, environment.environmentId, descriptor?.serverVersion),
);
return unsupportedSettingKeys.size === 0
? environment
: { ...environment, unsupportedSettingKeys };
}),
[dropped, environments],
);
const primaryEnvironmentId = usePrimaryEnvironmentId();
const search = useMemo(
() =>
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/components/settings/droppedSettingKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { Atom } from "effect/reactivity";

import { appAtomRegistry } from "../../rpc/atomRegistry";

interface DroppedSettingKeys {
readonly serverVersion: string;
readonly keys: ReadonlySet<string>;
}

/**
* Setting keys each environment dropped on a save this session, for servers
* too old to list the keys they store. Tagged with the server version, so an
* update clears them.
*/
export const droppedSettingKeysAtom = Atom.make<ReadonlyMap<EnvironmentId, DroppedSettingKeys>>(
new Map(),
).pipe(Atom.keepAlive, Atom.withLabel("settings:dropped-setting-keys"));

export function recordDroppedSettingKeys(
environmentId: EnvironmentId,
serverVersion: string,
keys: readonly string[],
) {
if (keys.length === 0) return;
appAtomRegistry.update(droppedSettingKeysAtom, (current) => {
const existing = current.get(environmentId);
const next = new Map(current);
next.set(environmentId, {
serverVersion,
keys: new Set([...(existing?.serverVersion === serverVersion ? existing.keys : []), ...keys]),
});
return next;
});
}

/** The keys an environment dropped, if it still runs the version that dropped them. */
export function droppedSettingKeysFor(
dropped: ReadonlyMap<EnvironmentId, DroppedSettingKeys>,
environmentId: EnvironmentId,
serverVersion: string | undefined,
): ReadonlySet<string> | undefined {
const entry = dropped.get(environmentId);
return entry && entry.serverVersion === serverVersion ? entry.keys : undefined;
}
103 changes: 103 additions & 0 deletions apps/web/src/components/settings/scopedSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import {
planScopedSettingsClear,
planScopedSettingsPatch,
resolveScopedSettingsTargets,
resolveUnsupportedSettingKeys,
scopedDisplaySettings,
scopedSettingsAreMixed,
scopedSettingsSource,
scopedSettingsUnsupportedTargets,
selectScopedSettingsEnvironments,
} from "./scopedSettings";
import { resolveSettingsScope } from "./settingsScope";
Expand Down Expand Up @@ -764,3 +767,103 @@ it("routes legacy model preference keys exclusively to the selected environment"
},
]);
});

describe("environments that cannot store a setting", () => {
const enabledLaptop = environment("Laptop", { settings: { reviewGuidesEnabled: true } });
const outdatedServer = {
...server,
unsupportedSettingKeys: new Set(["reviewGuidesEnabled", "defaultAutoPull"]),
};
const pair = [enabledLaptop, outdatedServer];
const pairScope = resolveSettingsScope({}, [], pair);
const targets = resolveScopedSettingsTargets(pairScope, pair);

it("reads unsupported keys from the advertised list, else from dropped writes", () => {
const advertised = Object.keys(DEFAULT_SERVER_SETTINGS).filter(
(key) => key !== "reviewGuidesEnabled",
);
expect([...resolveUnsupportedSettingKeys(advertised, new Set(["branchNamePrefix"]))]).toEqual([
"reviewGuidesEnabled",
]);
expect([...resolveUnsupportedSettingKeys(undefined, new Set(["reviewGuidesEnabled"]))]).toEqual(
["reviewGuidesEnabled"],
);
expect(resolveUnsupportedSettingKeys(undefined, undefined).size).toBe(0);
});

it("leaves them out of the mixed state and shows the value where it is stored", () => {
expect(scopedSettingsAreMixed(targets, ["reviewGuidesEnabled"])).toBe(false);
expect(scopedSettingsUnsupportedTargets(targets, ["reviewGuidesEnabled"])).toEqual([
targets[1],
]);
expect(scopedDisplaySettings(targets[1]!, targets).reviewGuidesEnabled).toBe(true);
// Keys the outdated environment does store still compare across both.
expect(
scopedSettingsAreMixed(
[
targets[0]!,
{ ...targets[1]!, settings: { ...targets[1]!.settings, branchNamePrefix: "x" } },
],
["branchNamePrefix"],
),
).toBe(true);
});

it("writes only where the setting can be stored", () => {
const plan = planScopedSettingsPatch(pairScope, pair, {
reviewGuidesEnabled: false,
branchNamePrefix: "feat",
});
expect(plan.serverWrites.map(({ environmentId, patch }) => ({ environmentId, patch }))).toEqual(
[
{
environmentId: enabledLaptop.environmentId,
patch: { reviewGuidesEnabled: false, branchNamePrefix: "feat" },
},
{ environmentId: server.environmentId, patch: { branchNamePrefix: "feat" } },
],
);
});

it("names the environments to update when none can store the setting", () => {
const plan = planScopedSettingsPatch(
resolveSettingsScope({ machine: server.environmentId }, [], pair),
pair,
{ reviewGuidesEnabled: true },
);
expect(plan.serverWrites).toEqual([]);
expect(plan.unavailableReason).toBe("Update T3 Code on Server to use this setting.");
});

it("skips project override writes for keys the environment cannot store", () => {
const plan = planScopedSettingsPatch(project, [laptop, outdatedServer], {
defaultAutoPull: true,
});
expect(plan.serverWrites.map(({ environmentId }) => environmentId)).toEqual([
laptop.environmentId,
]);
});

it("reports the setting keys an environment dropped, including inside project overrides", async () => {
const result = await persistScopedSettingsPatch(
planScopedSettingsPatch(project, environments, {
defaultAutoPull: true,
enableAgentBrowserAccess: false,
}),
async ({ environmentId }) => ({
_tag: "Success" as const,
value: {
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides:
environmentId === laptop.environmentId
? { [laptopProjectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false } }
: { [projectId]: { defaultAutoPull: true } },
},
}),
vi.fn(),
);
expect(
result.failedEnvironments.map(({ label, droppedKeys }) => ({ label, droppedKeys })),
).toEqual([{ label: server.label, droppedKeys: ["enableAgentBrowserAccess"] }]);
});
});
Loading
Loading