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
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 @@ -215,6 +215,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 @@ -253,6 +254,7 @@ export const make = Effect.gen(function* () {
serverResolvedCommandContext: true,
environmentIcon: true,
projectCloneTracking: true,
settingsKeys: Object.keys(ServerSettings.fields),
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverInstallation === null ? {} : { serverInstallation }),
// V2 restart recovery uses the environment-owned opt-in. The old
Expand Down
77 changes: 42 additions & 35 deletions apps/web/src/components/settings/SettingInheritance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export function SettingInheritance({
target,
environment: { ...environment, serverConfig: environment.serverConfig },
machine: resolveEnvironmentMachineKind(environment.serverConfig),
unsupported: target.unsupportedSettingKeys?.has(key) === true,
layers: settingInheritanceLayers(target, environment.serverConfig.settings, key),
},
];
Expand Down Expand Up @@ -227,7 +228,7 @@ export function SettingInheritance({
</Tooltip>
<PopoverPopup align="start" width="md" padding="none">
<div className="divide-y divide-border/60">
{chains.map(({ target, environment, machine, layers }) => (
{chains.map(({ target, environment, machine, unsupported, layers }) => (
<section
key={`${target.environmentId}:${target.projectId ?? ""}`}
className="px-3 py-2.5"
Expand All @@ -236,43 +237,49 @@ export function SettingInheritance({
<EnvironmentMachineIcon aria-hidden kind={machine} className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{target.label}</span>
</h4>
<ol role="list" className="text-sm">
{layers.map((layer) => (
<li
key={layer.key}
className={cn(
"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1",
layer.effective && "bg-foreground/[0.06]",
)}
>
<span
{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
key={layer.key}
className={cn(
"min-w-0 truncate",
layer.effective ? "font-medium text-foreground" : "text-muted-foreground",
"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1",
layer.effective && "bg-foreground/[0.06]",
)}
>
{layer.key === "environment" ? "Environment" : layer.label}
</span>
<span
className={cn(
"flex items-center gap-1.5 tabular-nums",
layer.effective
? "text-foreground"
: layer.set
? "text-muted-foreground"
: "text-muted-foreground/60",
)}
>
<span className="max-w-32 truncate">{layer.value}</span>
{layer.effective ? (
<CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
) : (
<span aria-hidden className="size-3.5 shrink-0" />
)}
</span>
</li>
))}
</ol>
<span
className={cn(
"min-w-0 truncate",
layer.effective ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{layer.key === "environment" ? "Environment" : layer.label}
</span>
<span
className={cn(
"flex items-center gap-1.5 tabular-nums",
layer.effective
? "text-foreground"
: layer.set
? "text-muted-foreground"
: "text-muted-foreground/60",
)}
>
<span className="max-w-32 truncate">{layer.value}</span>
{layer.effective ? (
<CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
) : (
<span aria-hidden className="size-3.5 shrink-0" />
)}
</span>
</li>
))}
</ol>
)}
{(() => {
const overriding = overridingProjects.filter(
(project) => project.environmentId === target.environmentId,
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 @@ -56,7 +61,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 @@ -750,3 +753,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