| 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 | */ |
| 11 | import type { |
| 12 | EnvironmentId, |
| 13 | ExecutionEnvironmentCapabilities, |
| 14 | ServerSettings, |
| 15 | ServerSettingsPatch, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings"; |
| 18 | import * as Equal from "effect/Equal"; |
| 19 | import * as Struct from "effect/Struct"; |
| 20 | |
| 21 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; |
| 22 | |
| 23 | /** Server keys that hold a user preference rather than machine config. */ |
| 24 | const 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 | |
| 35 | export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; |
| 36 | |
| 37 | const 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. */ |
| 40 | export 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. */ |
| 60 | export 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. */ |
| 90 | export 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 | */ |
| 105 | export 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 | |
| 119 | export 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 | */ |
| 137 | export 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 | } |