1/**
2 * Shared server settings.
3 *
4 * Every server keeps its own `settings.json`, but some keys are user
5 * preferences that only live on the server because the server has to act on
6 * them (auto-settlement runs with no client attached). A user does not want
7 * those to differ per machine. Clients write these keys to every shared-settings
8 * sync target, and warn when another target still holds a different value so
9 * the user can push their current value out.
10 */
11import type {
12 EnvironmentId,
13 ExecutionEnvironmentCapabilities,
14 ServerSettings,
15 ServerSettingsPatch,
16} from "@t3tools/contracts";
17import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings";
18import * as Equal from "effect/Equal";
19import * as Struct from "effect/Struct";
20
21import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
22
23/** Server keys that hold a user preference rather than machine config. */
24const SHARED_SERVER_SETTING_KEYS = [
25 "continueThreadsAfterServerUpdate",
26 "sidebarAutoSettleAfterDays",
27 "sidebarAutoSettleOnMerge",
28 "autoResumeLimitedThreads",
29 "snoozeLimitedThreads",
30 "newWorktreesStartFromOrigin",
31 "sourceControlWritingStyle",
32 "textGenerationModelSelection",
33] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>;
34
35export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number];
36
37const SHARED_KEY_SET = new Set<string>(SHARED_SERVER_SETTING_KEYS);
38
39/** Split a server patch into the keys every environment should receive and the primary-only rest. */
40export function splitSharedServerPatch(patch: ServerSettingsPatch): {
41 sharedPatch: ServerSettingsPatch;
42 localPatch: ServerSettingsPatch;
43} {
44 const sharedPatch: Record<string, unknown> = {};
45 const localPatch: Record<string, unknown> = {};
46 for (const [key, value] of Object.entries(patch)) {
47 if (SHARED_KEY_SET.has(key)) {
48 sharedPatch[key] = value;
49 } else {
50 localPatch[key] = value;
51 }
52 }
53 return {
54 sharedPatch: sharedPatch as ServerSettingsPatch,
55 localPatch: localPatch as ServerSettingsPatch,
56 };
57}
58
59/** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */
60export function filterSharedServerPatch(
61 patch: ServerSettingsPatch,
62 capabilities: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation"> | undefined,
63 settings?: ServerSettings,
64 sourceSettings = settings,
65 targetIsSource = false,
66): ServerSettingsPatch {
67 const instanceId =
68 patch.textGenerationModelSelection?.instanceId ??
69 sourceSettings?.textGenerationModelSelection.instanceId;
70 if (
71 !targetIsSource &&
72 patch.textGenerationModelSelection &&
73 (!settings ||
74 (instanceId !== undefined &&
75 (sourceSettings?.providerInstances[instanceId]?.driver ?? instanceId) !==
76 (settings.providerInstances[instanceId]?.driver ?? instanceId)) ||
77 !isModelSelectionProviderEnabled(settings, {
78 ...settings.textGenerationModelSelection,
79 ...patch.textGenerationModelSelection,
80 }))
81 ) {
82 patch = Struct.omit(patch, ["textGenerationModelSelection"]);
83 }
84 return capabilities?.threadRestartContinuation === true
85 ? patch
86 : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]);
87}
88
89/** The shared subset supported by one environment. */
90export function pickSharedServerSettings(
91 settings: ServerSettings,
92 capabilities?: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">,
93): ServerSettingsPatch {
94 return filterSharedServerPatch(
95 Struct.pick(settings, SHARED_SERVER_SETTING_KEYS),
96 capabilities,
97 settings,
98 );
99}
100
101/**
102 * Whether an environment can participate in shared-settings sync right now.
103 * Auto-settlement establishes baseline support; newer preferences are filtered separately.
104 */
105export function supportsSharedSettingsSync(environment: {
106 readonly connection: { readonly phase: EnvironmentConnectionPhase };
107 readonly serverConfig: {
108 readonly environment: {
109 readonly capabilities: Pick<ExecutionEnvironmentCapabilities, "threadAutoSettlement">;
110 };
111 } | null;
112}): boolean {
113 return (
114 environment.connection.phase === "connected" &&
115 environment.serverConfig?.environment.capabilities.threadAutoSettlement === true
116 );
117}
118
119export interface SharedSettingsEnvironment {
120 readonly environmentId: EnvironmentId;
121 readonly label: string;
122 readonly syncEligible: boolean;
123 readonly settings: ServerSettings | null;
124 readonly capabilities?:
125 | Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">
126 | undefined;
127}
128
129/**
130 * Shared-settings sync targets whose values differ from the primary
131 * environment's. Other environments are skipped: nothing can be read from or
132 * written to them, or their server lacks baseline shared-settings support. With no
133 * primary settings loaded there is nothing to compare against, so nothing is
134 * reported. Callers must pass the real loaded settings, never a default
135 * fallback, or "apply to all" would push defaults over real values.
136 */
137export function findSharedSettingsMismatches(input: {
138 readonly primaryEnvironmentId: EnvironmentId | null;
139 readonly primarySettings: ServerSettings | null;
140 readonly primaryCapabilities?:
141 | Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">
142 | undefined;
143 readonly environments: ReadonlyArray<SharedSettingsEnvironment>;
144}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> {
145 if (input.primaryEnvironmentId === null || input.primarySettings === null) {
146 return [];
147 }
148 const primarySettings = pickSharedServerSettings(
149 input.primarySettings,
150 input.primaryCapabilities,
151 );
152 return input.environments.flatMap((environment) => {
153 if (
154 environment.environmentId === input.primaryEnvironmentId ||
155 !environment.syncEligible ||
156 environment.settings === null
157 ) {
158 return [];
159 }
160 const expected = filterSharedServerPatch(
161 primarySettings,
162 environment.capabilities,
163 environment.settings,
164 input.primarySettings ?? undefined,
165 );
166 let actual = filterSharedServerPatch(
167 pickSharedServerSettings(environment.settings, environment.capabilities),
168 input.primaryCapabilities,
169 environment.settings,
170 );
171 if (!expected.textGenerationModelSelection) {
172 actual = Struct.omit(actual, ["textGenerationModelSelection"]);
173 }
174 return Equal.equals(actual, expected)
175 ? []
176 : [{ environmentId: environment.environmentId, label: environment.label }];
177 });
178}