| 1 | /** |
| 2 | * ServerSettings - Server-authoritative settings service. |
| 3 | * |
| 4 | * Owns persistence, validation, and change notification of settings that affect |
| 5 | * server-side behavior (binary paths, streaming mode, env mode, custom models, |
| 6 | * text generation model selection). |
| 7 | * |
| 8 | * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + |
| 9 | * Semaphore + FileSystem.watch for concurrency and external edit detection. |
| 10 | * |
| 11 | * @module ServerSettings |
| 12 | */ |
| 13 | import { |
| 14 | DEFAULT_SERVER_SETTINGS, |
| 15 | ModelSelection, |
| 16 | ProjectId, |
| 17 | ProjectScript, |
| 18 | ProjectSettingsOverrides, |
| 19 | type ProviderInstanceConfig, |
| 20 | type ProviderInstanceMutation, |
| 21 | defaultInstanceIdForDriver, |
| 22 | isProviderDriverKind, |
| 23 | ProviderDriverKind, |
| 24 | ProviderInstanceId, |
| 25 | resolveProviderInstanceEnabled, |
| 26 | ResponseStreamingMode, |
| 27 | ServerSettings, |
| 28 | ServerSettingsError, |
| 29 | type ServerSettingsPatch, |
| 30 | } from "@t3tools/contracts"; |
| 31 | import * as Cache from "effect/Cache"; |
| 32 | import * as Cause from "effect/Cause"; |
| 33 | import * as Context from "effect/Context"; |
| 34 | import * as Deferred from "effect/Deferred"; |
| 35 | import * as Duration from "effect/Duration"; |
| 36 | import * as Equal from "effect/Equal"; |
| 37 | import * as Effect from "effect/Effect"; |
| 38 | import * as Exit from "effect/Exit"; |
| 39 | import * as FileSystem from "effect/FileSystem"; |
| 40 | import * as Layer from "effect/Layer"; |
| 41 | import * as Option from "effect/Option"; |
| 42 | import * as Path from "effect/Path"; |
| 43 | import * as PubSub from "effect/PubSub"; |
| 44 | import * as Ref from "effect/Ref"; |
| 45 | import * as Schema from "effect/Schema"; |
| 46 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 47 | import * as Semaphore from "effect/Semaphore"; |
| 48 | import * as Scope from "effect/Scope"; |
| 49 | import * as Stream from "effect/Stream"; |
| 50 | import * as SqlClient from "effect/sql/SqlClient"; |
| 51 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 52 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; |
| 53 | import * as ServerConfig from "./config.ts"; |
| 54 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 55 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 56 | import { |
| 57 | applyServerSettingsPatch, |
| 58 | deriveLegacyProjectOverrides, |
| 59 | fallbackTextGenerationModelSelection, |
| 60 | isModelSelectionProviderEnabled, |
| 61 | } from "@t3tools/shared/serverSettings"; |
| 62 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 63 | import { |
| 64 | readSecretFieldValues, |
| 65 | sourceControlHostSecretName, |
| 66 | } from "./sourceControl/sourceControlHostSecrets.ts"; |
| 67 | |
| 68 | import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; |
| 69 | |
| 70 | export { |
| 71 | resolveReviewGuideModelSelection, |
| 72 | resolveSourceControlWriterModelSelection, |
| 73 | } from "@t3tools/shared/serverSettings"; |
| 74 | |
| 75 | const encodeServerSettings = Schema.encodeEffect(ServerSettings); |
| 76 | const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); |
| 77 | const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); |
| 78 | |
| 79 | const textEncoder = new TextEncoder(); |
| 80 | const textDecoder = new TextDecoder(); |
| 81 | |
| 82 | /** |
| 83 | * Fold the legacy in-config `enabled` flag into the envelope-level |
| 84 | * `ProviderInstanceConfig.enabled` and strip it from the config blob, so |
| 85 | * explicit provider instances carry exactly one enabled flag. Old settings |
| 86 | * files can hold both flags with conflicting values; an explicit false on |
| 87 | * either side wins so a user's disable is never silently undone. Runs on |
| 88 | * every load and update — the file converges on the next write. |
| 89 | */ |
| 90 | const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => { |
| 91 | let changed = false; |
| 92 | const providerInstances: Record<string, ProviderInstanceConfig> = {}; |
| 93 | for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { |
| 94 | const config = instance.config; |
| 95 | // Only fold boolean flags: a malformed `enabled` (e.g. `"false"`) must |
| 96 | // stay in the blob so driver schema validation flags it instead of the |
| 97 | // fold silently repairing the config. |
| 98 | if ( |
| 99 | config === null || |
| 100 | typeof config !== "object" || |
| 101 | Array.isArray(config) || |
| 102 | typeof (config as { readonly enabled?: unknown }).enabled !== "boolean" |
| 103 | ) { |
| 104 | providerInstances[instanceId] = instance; |
| 105 | continue; |
| 106 | } |
| 107 | const { enabled: configEnabled, ...restConfig } = config as Record<string, unknown> & { |
| 108 | readonly enabled: boolean; |
| 109 | }; |
| 110 | const resolved = |
| 111 | instance.enabled === false || configEnabled === false |
| 112 | ? false |
| 113 | : (instance.enabled ?? configEnabled); |
| 114 | changed = true; |
| 115 | providerInstances[instanceId] = { |
| 116 | ...instance, |
| 117 | enabled: resolved, |
| 118 | config: restConfig, |
| 119 | } satisfies ProviderInstanceConfig; |
| 120 | } |
| 121 | if (!changed) { |
| 122 | return settings; |
| 123 | } |
| 124 | return { |
| 125 | ...settings, |
| 126 | providerInstances: providerInstances as ServerSettings["providerInstances"], |
| 127 | }; |
| 128 | }; |
| 129 | |
| 130 | const normalizeServerSettings = ( |
| 131 | settings: ServerSettings, |
| 132 | ): Effect.Effect<ServerSettings, ServerSettingsError> => |
| 133 | encodeServerSettings(settings).pipe( |
| 134 | Effect.flatMap(decodeServerSettings), |
| 135 | Effect.map(foldProviderInstanceEnabledFlags), |
| 136 | Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), |
| 137 | Effect.mapError( |
| 138 | (cause) => |
| 139 | new ServerSettingsError({ |
| 140 | settingsPath: "<memory>", |
| 141 | operation: "normalize", |
| 142 | cause, |
| 143 | }), |
| 144 | ), |
| 145 | ); |
| 146 | |
| 147 | export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { |
| 148 | return mapSettingsSecrets(settings, (secret) => |
| 149 | secret.sensitive |
| 150 | ? { value: "", redacted: secret.redacted || secret.value.length > 0 } |
| 151 | : { value: secret.value, redacted: false }, |
| 152 | ); |
| 153 | } |
| 154 | |
| 155 | export function applyProviderInstanceMutation( |
| 156 | settings: ServerSettings, |
| 157 | mutation: ProviderInstanceMutation, |
| 158 | ): ServerSettings { |
| 159 | const providerInstances = { ...settings.providerInstances }; |
| 160 | if (mutation.operation === "upsert" || mutation.operation === "create") { |
| 161 | providerInstances[mutation.instanceId] = mutation.instance; |
| 162 | } else { |
| 163 | delete providerInstances[mutation.instanceId]; |
| 164 | } |
| 165 | return { ...settings, providerInstances }; |
| 166 | } |
| 167 | |
| 168 | function ensureProviderInstanceMutationAllowed( |
| 169 | settings: ServerSettings, |
| 170 | mutation: ProviderInstanceMutation, |
| 171 | settingsPath: string, |
| 172 | ): Effect.Effect<void, ServerSettingsError> { |
| 173 | if ( |
| 174 | mutation.operation === "create" && |
| 175 | settings.providerInstances[mutation.instanceId] !== undefined |
| 176 | ) { |
| 177 | return Effect.fail( |
| 178 | new ServerSettingsError({ |
| 179 | settingsPath, |
| 180 | operation: "create-provider-instance", |
| 181 | providerInstanceId: mutation.instanceId, |
| 182 | }), |
| 183 | ); |
| 184 | } |
| 185 | return Effect.void; |
| 186 | } |
| 187 | |
| 188 | export class ServerSettingsService extends Context.Service< |
| 189 | ServerSettingsService, |
| 190 | { |
| 191 | /** Start the settings runtime and attach file watching. */ |
| 192 | readonly start: Effect.Effect<void, ServerSettingsError>; |
| 193 | |
| 194 | /** Await settings runtime readiness. */ |
| 195 | readonly ready: Effect.Effect<void, ServerSettingsError>; |
| 196 | |
| 197 | /** Read the current settings. */ |
| 198 | readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>; |
| 199 | |
| 200 | /** Patch settings and persist. Returns the new full settings object. */ |
| 201 | readonly updateSettings: ( |
| 202 | patch: ServerSettingsPatch, |
| 203 | ) => Effect.Effect<ServerSettings, ServerSettingsError>; |
| 204 | |
| 205 | /** Apply a patch and one provider-instance mutation against the same latest settings snapshot. */ |
| 206 | readonly updateProviderInstance: ( |
| 207 | mutation: ProviderInstanceMutation, |
| 208 | patch?: ServerSettingsPatch, |
| 209 | ) => Effect.Effect<ServerSettings, ServerSettingsError>; |
| 210 | |
| 211 | /** Run an effect against a settings snapshot while settings writes are paused. */ |
| 212 | readonly withSettingsSnapshot: <A, E, R>( |
| 213 | use: (settings: ServerSettings) => Effect.Effect<A, E, R>, |
| 214 | ) => Effect.Effect<A, E | ServerSettingsError, R>; |
| 215 | |
| 216 | /** Stream of settings change events. */ |
| 217 | readonly streamChanges: Stream.Stream<ServerSettings>; |
| 218 | |
| 219 | /** |
| 220 | * Acquire a settings change subscription synchronously in the current |
| 221 | * fiber. Use this before reading a snapshot when changes between the |
| 222 | * snapshot and a lazily started stream must not be lost. |
| 223 | */ |
| 224 | readonly subscribeChanges: Effect.Effect<Stream.Stream<ServerSettings>, never, Scope.Scope>; |
| 225 | } |
| 226 | >()("t3/serverSettings/ServerSettingsService") { |
| 227 | /** @deprecated Import and use `layerTest` from this module. */ |
| 228 | static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) => layerTest(overrides); |
| 229 | } |
| 230 | |
| 231 | const makeTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 232 | Effect.gen(function* () { |
| 233 | const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } = |
| 234 | overrides; |
| 235 | const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); |
| 236 | const initialSettings = yield* normalizeServerSettings({ |
| 237 | ...merged, |
| 238 | ...(automaticGitFetchInterval !== undefined |
| 239 | ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } |
| 240 | : {}), |
| 241 | ...(providerHealthRefreshInterval !== undefined |
| 242 | ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration } |
| 243 | : {}), |
| 244 | }); |
| 245 | const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings); |
| 246 | const writeSemaphore = yield* Semaphore.make(1); |
| 247 | const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)); |
| 248 | |
| 249 | const updateTestSettings = ( |
| 250 | update: (current: ServerSettings) => Effect.Effect<ServerSettings, ServerSettingsError>, |
| 251 | ): Effect.Effect<ServerSettings, ServerSettingsError> => |
| 252 | writeSemaphore.withPermits(1)( |
| 253 | Ref.get(currentSettingsRef).pipe( |
| 254 | Effect.flatMap(update), |
| 255 | Effect.flatMap(normalizeServerSettings), |
| 256 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), |
| 257 | Effect.map(resolveTextGenerationProvider), |
| 258 | ), |
| 259 | ); |
| 260 | |
| 261 | return { |
| 262 | start: Effect.void, |
| 263 | ready: Effect.void, |
| 264 | getSettings, |
| 265 | updateSettings: (patch) => |
| 266 | updateTestSettings((currentSettings) => |
| 267 | Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), |
| 268 | ), |
| 269 | updateProviderInstance: (mutation, patch = {}) => |
| 270 | updateTestSettings((currentSettings) => |
| 271 | Effect.gen(function* () { |
| 272 | yield* ensureProviderInstanceMutationAllowed( |
| 273 | currentSettings, |
| 274 | mutation, |
| 275 | "test settings", |
| 276 | ); |
| 277 | const patched = applyServerSettingsPatch(currentSettings, patch); |
| 278 | return applyProviderInstanceMutation(patched, mutation); |
| 279 | }), |
| 280 | ), |
| 281 | withSettingsSnapshot: (use) => |
| 282 | writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))), |
| 283 | streamChanges: Stream.empty, |
| 284 | subscribeChanges: Effect.succeed(Stream.empty), |
| 285 | } satisfies ServerSettingsService["Service"]; |
| 286 | }); |
| 287 | |
| 288 | export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) => |
| 289 | Layer.effect(ServerSettingsService, makeTest(overrides)); |
| 290 | |
| 291 | // Migrate saved token delivery without accepting it in settings writes or |
| 292 | // letting one retired value reset the rest of the environment's settings. |
| 293 | const PersistedResponseStreamingMode = Schema.Union([ |
| 294 | ResponseStreamingMode, |
| 295 | Schema.Literal("token"), |
| 296 | ]).pipe( |
| 297 | Schema.decodeTo( |
| 298 | ResponseStreamingMode, |
| 299 | SchemaTransformation.transform({ |
| 300 | decode: (mode) => (mode === "token" ? "paragraph" : mode), |
| 301 | encode: (mode) => mode, |
| 302 | }), |
| 303 | ), |
| 304 | ); |
| 305 | const ServerSettingsJson = fromLenientJson( |
| 306 | Schema.Struct({ |
| 307 | ...ServerSettings.fields, |
| 308 | responseStreamingMode: PersistedResponseStreamingMode.pipe( |
| 309 | Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), |
| 310 | ), |
| 311 | projectSettingsOverrides: Schema.Record( |
| 312 | ProjectId, |
| 313 | Schema.Struct({ |
| 314 | ...ProjectSettingsOverrides.fields, |
| 315 | responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), |
| 316 | }), |
| 317 | ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 318 | }), |
| 319 | ); |
| 320 | const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); |
| 321 | /** |
| 322 | * The retired `providers.<kind>` map, read without its old schemas. Before |
| 323 | * `providerInstances` existed each built-in driver had one blob there; it |
| 324 | * now only feeds `migrateLegacyProviderSettings`. |
| 325 | */ |
| 326 | const LegacyProviderSettingsJson = fromLenientJson( |
| 327 | Schema.Struct({ |
| 328 | providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), |
| 329 | // Retired for `sourceControlHosts`; read only by `migrateLegacySourceControlSettings`. |
| 330 | bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), |
| 331 | github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), |
| 332 | }), |
| 333 | ); |
| 334 | const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); |
| 335 | |
| 336 | /** The secret store names the retired `bitbucket` and `github` keys kept their tokens under. */ |
| 337 | const LEGACY_BITBUCKET_SECRET_NAMES: Readonly<Record<string, string>> = { |
| 338 | accessToken: "bitbucket-access-token", |
| 339 | apiToken: "bitbucket-api-token", |
| 340 | }; |
| 341 | const legacyGitHubTokenSecretName = (host: string) => |
| 342 | `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`; |
| 343 | |
| 344 | /** |
| 345 | * Folds the retired `bitbucket` and `github` keys into `sourceControlHosts`, unless the new key |
| 346 | * already holds that host. Returns the secret store entries to copy to their new names. |
| 347 | */ |
| 348 | function migrateLegacySourceControlSettings( |
| 349 | settings: ServerSettings, |
| 350 | legacy: { |
| 351 | readonly bitbucket?: Readonly<Record<string, unknown>> | undefined; |
| 352 | readonly github?: Readonly<Record<string, unknown>> | undefined; |
| 353 | }, |
| 354 | ): { |
| 355 | readonly settings: ServerSettings; |
| 356 | readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>; |
| 357 | } { |
| 358 | const sourceControlHosts = { ...settings.sourceControlHosts }; |
| 359 | const secretMoves: Array<{ readonly from: string; readonly to: string }> = []; |
| 360 | if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) { |
| 361 | sourceControlHosts.bitbucket = legacy.bitbucket; |
| 362 | for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) { |
| 363 | if (legacy.bitbucket[field] !== SECRET_REDACTED) continue; |
| 364 | secretMoves.push({ |
| 365 | from, |
| 366 | to: sourceControlHostSecretName({ kind: "bitbucket", field, serverHost: null }), |
| 367 | }); |
| 368 | } |
| 369 | } |
| 370 | if (legacy.github !== undefined && sourceControlHosts.github === undefined) { |
| 371 | sourceControlHosts.github = legacy.github; |
| 372 | for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) { |
| 373 | if (serverHost === null || value !== SECRET_REDACTED) continue; |
| 374 | secretMoves.push({ |
| 375 | from: legacyGitHubTokenSecretName(serverHost), |
| 376 | to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }), |
| 377 | }); |
| 378 | } |
| 379 | } |
| 380 | return { settings: { ...settings, sourceControlHosts }, secretMoves }; |
| 381 | } |
| 382 | |
| 383 | // Drivers that start disabled, so a session in the history means the user |
| 384 | // turned them on before the instance kept an explicit flag. |
| 385 | const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set( |
| 386 | ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)), |
| 387 | ); |
| 388 | |
| 389 | /** |
| 390 | * Move each customized legacy `providers.<kind>` blob into the driver's |
| 391 | * default `providerInstances` slot. An explicit instance already in that slot |
| 392 | * wins. Blobs that match a fresh install (empty, or only an `enabled` flag |
| 393 | * equal to the driver default) carry nothing and are dropped. |
| 394 | * |
| 395 | * A cursor/grok/opencode slot without an `enabled` flag is enabled when |
| 396 | * provider history shows the driver was used, which is how those drivers |
| 397 | * were opted into before they had an explicit flag. Callers run this only |
| 398 | * while the settings file still has the retired map, so it happens once. |
| 399 | */ |
| 400 | function migrateLegacyProviderSettings( |
| 401 | settings: ServerSettings, |
| 402 | legacyProviders: Readonly<Record<string, unknown>>, |
| 403 | providerHistory: ReadonlyArray<{ |
| 404 | readonly providerName: string; |
| 405 | readonly providerInstanceId: string | null; |
| 406 | }>, |
| 407 | ): ServerSettings { |
| 408 | // History rows are raw SQL text; compare them as strings. |
| 409 | const usedProviders = new Set<string>(providerHistory.map(({ providerName }) => providerName)); |
| 410 | const usedProviderInstances = new Set<string>( |
| 411 | providerHistory.map( |
| 412 | ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, |
| 413 | ), |
| 414 | ); |
| 415 | |
| 416 | const providerInstances: Record<ProviderInstanceId, ProviderInstanceConfig> = {}; |
| 417 | for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array< |
| 418 | [ProviderInstanceId, ProviderInstanceConfig] |
| 419 | >) { |
| 420 | providerInstances[instanceId] = |
| 421 | instance.enabled === undefined && |
| 422 | HISTORY_RESTORED_DRIVERS.has(instance.driver) && |
| 423 | usedProviderInstances.has(instanceId) |
| 424 | ? { ...instance, enabled: true } |
| 425 | : instance; |
| 426 | } |
| 427 | |
| 428 | // History restores a never-configured cursor/grok/opencode slot too. |
| 429 | const legacyEntries = new Map(Object.entries(legacyProviders)); |
| 430 | for (const driver of HISTORY_RESTORED_DRIVERS) { |
| 431 | if (!legacyEntries.has(driver)) legacyEntries.set(driver, {}); |
| 432 | } |
| 433 | for (const [kind, blob] of legacyEntries) { |
| 434 | if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue; |
| 435 | if (Array.isArray(blob)) continue; |
| 436 | const driver = kind; |
| 437 | const instanceId = defaultInstanceIdForDriver(driver); |
| 438 | if (Object.hasOwn(providerInstances, instanceId)) continue; |
| 439 | |
| 440 | const { enabled: rawEnabled, ...config } = blob as Record<string, unknown>; |
| 441 | const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined; |
| 442 | const enabled = |
| 443 | explicitEnabled ?? |
| 444 | (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined); |
| 445 | const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} }); |
| 446 | if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) { |
| 447 | continue; |
| 448 | } |
| 449 | providerInstances[instanceId] = { |
| 450 | driver, |
| 451 | ...(enabled === undefined ? {} : { enabled }), |
| 452 | config, |
| 453 | } satisfies ProviderInstanceConfig; |
| 454 | } |
| 455 | |
| 456 | return { |
| 457 | ...settings, |
| 458 | providerInstances, |
| 459 | }; |
| 460 | } |
| 461 | |
| 462 | const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); |
| 463 | |
| 464 | /** ACP Registry instances reject every application text-generation operation. */ |
| 465 | function selectionSupportsTextGeneration( |
| 466 | settings: ServerSettings, |
| 467 | selection: ModelSelection, |
| 468 | ): boolean { |
| 469 | return settings.providerInstances[selection.instanceId]?.driver !== ACP_REGISTRY_DRIVER; |
| 470 | } |
| 471 | |
| 472 | function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| 473 | return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) && |
| 474 | selectionSupportsTextGeneration(settings, settings.textGenerationModelSelection) |
| 475 | ? settings |
| 476 | : fallbackTextGenerationProvider(settings); |
| 477 | } |
| 478 | |
| 479 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| 480 | // A slot without an explicit instance uses its driver's default enabled state. |
| 481 | const fallback = fallbackTextGenerationModelSelection( |
| 482 | settings.providerInstances, |
| 483 | (instanceId, driver) => |
| 484 | resolveProviderInstanceEnabled( |
| 485 | settings.providerInstances[instanceId] ?? { driver, config: {} }, |
| 486 | ), |
| 487 | { defaultModelSelection: settings.defaultModelSelection }, |
| 488 | ); |
| 489 | return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; |
| 490 | } |
| 491 | |
| 492 | // Values under these keys are compared as a whole — never stripped field-by-field. |
| 493 | const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ |
| 494 | "backgroundActivity", |
| 495 | "automaticGitFetchInterval", |
| 496 | "providerHealthRefreshInterval", |
| 497 | "sourceControlWriterModelSelection", |
| 498 | "reviewGuideModelSelection", |
| 499 | "textGenerationModelSelection", |
| 500 | "pullRequestMergeMethod", |
| 501 | ]); |
| 502 | |
| 503 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { |
| 504 | if (Array.isArray(current) || Array.isArray(defaults)) { |
| 505 | return Equal.equals(current, defaults) ? undefined : current; |
| 506 | } |
| 507 | |
| 508 | if ( |
| 509 | current !== null && |
| 510 | defaults !== null && |
| 511 | typeof current === "object" && |
| 512 | typeof defaults === "object" |
| 513 | ) { |
| 514 | const currentRecord = current as Record<string, unknown>; |
| 515 | const defaultsRecord = defaults as Record<string, unknown>; |
| 516 | const next: Record<string, unknown> = {}; |
| 517 | |
| 518 | for (const key of Object.keys(currentRecord)) { |
| 519 | if (ATOMIC_SETTINGS_KEYS.has(key)) { |
| 520 | if (!Equal.equals(currentRecord[key], defaultsRecord[key])) { |
| 521 | next[key] = currentRecord[key]; |
| 522 | } |
| 523 | } else { |
| 524 | const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]); |
| 525 | if (stripped !== undefined) { |
| 526 | next[key] = stripped; |
| 527 | } |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | return Object.keys(next).length > 0 ? next : undefined; |
| 532 | } |
| 533 | |
| 534 | return Object.is(current, defaults) ? undefined : current; |
| 535 | } |
| 536 | |
| 537 | const decodeProjectScriptsJson = Schema.decodeUnknownOption( |
| 538 | Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 539 | ); |
| 540 | const decodeModelSelectionJson = Schema.decodeUnknownOption( |
| 541 | Schema.fromJsonString(Schema.NullOr(ModelSelection)), |
| 542 | ); |
| 543 | |
| 544 | interface LegacyProjectSettingsRow { |
| 545 | readonly projectId: string; |
| 546 | readonly defaultModelSelection: string | null; |
| 547 | readonly defaultThreadEnvMode: string | null; |
| 548 | readonly autoPull: number; |
| 549 | readonly scripts: string; |
| 550 | } |
| 551 | |
| 552 | /** |
| 553 | * One-time fold of the legacy per-project fields into `projectSettingsOverrides`: |
| 554 | * the three `project*Overrides` maps and the settings columns on the project |
| 555 | * aggregate. Keys already present in the generic record win. Marked with |
| 556 | * `projectSettingsFolded` so a later reset in the UI survives restarts. |
| 557 | */ |
| 558 | function foldLegacyProjectSettings( |
| 559 | settings: ServerSettings, |
| 560 | rows: ReadonlyArray<LegacyProjectSettingsRow>, |
| 561 | ): ServerSettings { |
| 562 | if (settings.projectSettingsFolded) return settings; |
| 563 | // Nothing to fold yet (fresh install): leave the marker off so the file |
| 564 | // stays sparse, and check again on the next load. |
| 565 | if ( |
| 566 | rows.length === 0 && |
| 567 | Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 && |
| 568 | Object.keys(settings.projectAutoPullOverrides).length === 0 && |
| 569 | Object.keys(settings.projectScriptOverrides).length === 0 |
| 570 | ) { |
| 571 | return settings; |
| 572 | } |
| 573 | const entries: Record<string, ProjectSettingsOverrides> = { |
| 574 | ...settings.projectSettingsOverrides, |
| 575 | }; |
| 576 | const set = <K extends keyof ProjectSettingsOverrides>( |
| 577 | projectId: string, |
| 578 | key: K, |
| 579 | value: ProjectSettingsOverrides[K] | undefined, |
| 580 | ) => { |
| 581 | if (value === undefined) return; |
| 582 | const entry = entries[projectId] ?? {}; |
| 583 | if (Object.hasOwn(entry, key)) return; |
| 584 | entries[projectId] = { ...entry, [key]: value }; |
| 585 | }; |
| 586 | for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) { |
| 587 | set(projectId, "enableAgentBrowserAccess", value); |
| 588 | } |
| 589 | for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { |
| 590 | set(projectId, "defaultAutoPull", value); |
| 591 | } |
| 592 | // A stored null meant "reset to machine defaults", which is now plain |
| 593 | // inheritance; the project's own aggregate scripts must not resurface. |
| 594 | const resetScripts = new Set<string>(); |
| 595 | for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { |
| 596 | if (value === null) resetScripts.add(projectId); |
| 597 | else set(projectId, "defaultProjectScripts", value); |
| 598 | } |
| 599 | for (const row of rows) { |
| 600 | const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null"); |
| 601 | if (Option.isSome(model) && model.value !== null) { |
| 602 | set(row.projectId, "defaultModelSelection", model.value); |
| 603 | } |
| 604 | if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") { |
| 605 | set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode); |
| 606 | } |
| 607 | if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true); |
| 608 | const scripts = decodeProjectScriptsJson(row.scripts); |
| 609 | if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) { |
| 610 | set(row.projectId, "defaultProjectScripts", scripts.value); |
| 611 | } |
| 612 | } |
| 613 | const projectSettingsOverrides = Object.fromEntries( |
| 614 | Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0), |
| 615 | ); |
| 616 | return { |
| 617 | ...settings, |
| 618 | projectSettingsOverrides, |
| 619 | projectSettingsFolded: true, |
| 620 | ...deriveLegacyProjectOverrides({ projectSettingsOverrides }), |
| 621 | }; |
| 622 | } |
| 623 | |
| 624 | const make = Effect.gen(function* () { |
| 625 | const { settingsPath } = yield* ServerConfig.ServerConfig; |
| 626 | const fs = yield* FileSystem.FileSystem; |
| 627 | const pathService = yield* Path.Path; |
| 628 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 629 | const sql = yield* SqlClient.SqlClient; |
| 630 | const writeSemaphore = yield* Semaphore.make(1); |
| 631 | const cacheKey = "settings" as const; |
| 632 | const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); |
| 633 | const startedRef = yield* Ref.make(false); |
| 634 | const startedDeferred = yield* Deferred.make<void, ServerSettingsError>(); |
| 635 | const watcherScope = yield* Scope.make("sequential"); |
| 636 | yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); |
| 637 | |
| 638 | const emitChange = (settings: ServerSettings) => |
| 639 | PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); |
| 640 | |
| 641 | const readConfigExists = fs.exists(settingsPath).pipe( |
| 642 | Effect.mapError( |
| 643 | (cause) => |
| 644 | new ServerSettingsError({ |
| 645 | settingsPath, |
| 646 | operation: "check-exists", |
| 647 | cause, |
| 648 | }), |
| 649 | ), |
| 650 | ); |
| 651 | |
| 652 | const readRawConfig = fs.readFileString(settingsPath).pipe( |
| 653 | Effect.mapError( |
| 654 | (cause) => |
| 655 | new ServerSettingsError({ |
| 656 | settingsPath, |
| 657 | operation: "read-file", |
| 658 | cause, |
| 659 | }), |
| 660 | ), |
| 661 | ); |
| 662 | |
| 663 | const writeSettingsAtomically = Effect.fnUntraced( |
| 664 | function* (settings: ServerSettings) { |
| 665 | const sparseSettingsJson = yield* encodeServerSettingsJson( |
| 666 | stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, |
| 667 | ); |
| 668 | |
| 669 | return yield* writeFileStringAtomically({ |
| 670 | filePath: settingsPath, |
| 671 | contents: `${sparseSettingsJson}\n`, |
| 672 | }).pipe( |
| 673 | Effect.provideService(FileSystem.FileSystem, fs), |
| 674 | Effect.provideService(Path.Path, pathService), |
| 675 | ); |
| 676 | }, |
| 677 | Effect.mapError( |
| 678 | (cause) => |
| 679 | new ServerSettingsError({ |
| 680 | settingsPath, |
| 681 | operation: "write-file", |
| 682 | cause, |
| 683 | }), |
| 684 | ), |
| 685 | ); |
| 686 | |
| 687 | /** |
| 688 | * Migrate plaintext schema-declared credentials on load. Failed moves stay usable from |
| 689 | * the file and are retried next time; client redaction does not depend on migration. |
| 690 | */ |
| 691 | const moveInlineSettingsSecrets = (settings: ServerSettings) => |
| 692 | Effect.gen(function* () { |
| 693 | const pending: SettingsSecret[] = []; |
| 694 | mapSettingsSecrets(settings, (secret) => { |
| 695 | if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) { |
| 696 | pending.push(secret); |
| 697 | } |
| 698 | return secret; |
| 699 | }); |
| 700 | const moved = new Set<string>(); |
| 701 | for (const secret of pending) { |
| 702 | const stored = yield* secretStore |
| 703 | .set(secret.secretName, textEncoder.encode(secret.value)) |
| 704 | .pipe( |
| 705 | Effect.as(true), |
| 706 | Effect.catch(() => |
| 707 | Effect.logWarning("failed to move a settings secret into the secret store").pipe( |
| 708 | Effect.as(false), |
| 709 | ), |
| 710 | ), |
| 711 | ); |
| 712 | if (stored) moved.add(secret.secretName); |
| 713 | } |
| 714 | if (moved.size === 0) return settings; |
| 715 | return mapSettingsSecrets(settings, (secret) => |
| 716 | moved.has(secret.secretName) ? { value: "", redacted: true } : secret, |
| 717 | ); |
| 718 | }); |
| 719 | |
| 720 | const loadSettingsFromDisk = Effect.gen(function* () { |
| 721 | let settings = DEFAULT_SERVER_SETTINGS; |
| 722 | let legacyProviders: Readonly<Record<string, unknown>> | undefined; |
| 723 | let legacySourceControl: Parameters<typeof migrateLegacySourceControlSettings>[1] = {}; |
| 724 | // A file that failed to decode must stay on disk for the user to repair; |
| 725 | // the fold below only writes when it started from the file's real contents. |
| 726 | let settingsFileTrusted = true; |
| 727 | |
| 728 | if (yield* readConfigExists) { |
| 729 | const raw = yield* readRawConfig; |
| 730 | const decoded = decodeServerSettingsJsonExit(raw); |
| 731 | const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); |
| 732 | if (legacySettings._tag === "Success") { |
| 733 | legacyProviders = legacySettings.value.providers; |
| 734 | legacySourceControl = { |
| 735 | bitbucket: legacySettings.value.bitbucket, |
| 736 | github: legacySettings.value.github, |
| 737 | }; |
| 738 | } |
| 739 | if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { |
| 740 | const failure = decoded._tag === "Failure" ? decoded : legacySettings; |
| 741 | settingsFileTrusted = false; |
| 742 | // The file may hold an opt-out we can no longer read; never resume threads on a guess. |
| 743 | settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false }; |
| 744 | if (failure._tag === "Failure") { |
| 745 | yield* Effect.logWarning("failed to parse settings.json, using defaults", { |
| 746 | path: settingsPath, |
| 747 | issues: Cause.pretty(failure.cause), |
| 748 | cause: failure.cause, |
| 749 | }); |
| 750 | } |
| 751 | } else { |
| 752 | settings = decoded.value; |
| 753 | } |
| 754 | } |
| 755 | |
| 756 | const providerHistory = yield* sql<{ |
| 757 | readonly providerName: string; |
| 758 | readonly providerInstanceId: string | null; |
| 759 | }>` |
| 760 | SELECT DISTINCT |
| 761 | provider_name AS "providerName", |
| 762 | provider_instance_id AS "providerInstanceId" |
| 763 | FROM projection_thread_sessions |
| 764 | WHERE provider_name IN ('cursor', 'grok', 'opencode') |
| 765 | UNION |
| 766 | SELECT DISTINCT |
| 767 | provider_name AS "providerName", |
| 768 | provider_instance_id AS "providerInstanceId" |
| 769 | FROM provider_session_runtime |
| 770 | WHERE provider_name IN ('cursor', 'grok', 'opencode') |
| 771 | `.pipe( |
| 772 | Effect.mapError( |
| 773 | (cause) => |
| 774 | new ServerSettingsError({ |
| 775 | settingsPath, |
| 776 | operation: "read-provider-history", |
| 777 | cause, |
| 778 | }), |
| 779 | ), |
| 780 | ); |
| 781 | |
| 782 | const legacyProjectRows = |
| 783 | settings.projectSettingsFolded || !settingsFileTrusted |
| 784 | ? [] |
| 785 | : yield* sql<LegacyProjectSettingsRow>` |
| 786 | SELECT |
| 787 | project_id AS "projectId", |
| 788 | default_model_selection_json AS "defaultModelSelection", |
| 789 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 790 | auto_pull AS "autoPull", |
| 791 | scripts_json AS "scripts" |
| 792 | FROM projection_projects |
| 793 | WHERE deleted_at IS NULL |
| 794 | `.pipe( |
| 795 | Effect.mapError( |
| 796 | (cause) => |
| 797 | new ServerSettingsError({ |
| 798 | settingsPath, |
| 799 | operation: "read-project-settings", |
| 800 | cause, |
| 801 | }), |
| 802 | ), |
| 803 | ); |
| 804 | |
| 805 | const loaded = foldProviderInstanceEnabledFlags( |
| 806 | legacyProviders === undefined |
| 807 | ? settings |
| 808 | : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory), |
| 809 | ); |
| 810 | const folded = settingsFileTrusted |
| 811 | ? foldLegacyProjectSettings(loaded, legacyProjectRows) |
| 812 | : loaded; |
| 813 | // Copied, not moved: the old entry is removed only after the new file is written. A token |
| 814 | // that cannot be read or copied leaves the retired keys in place, so the migration runs again |
| 815 | // on the next load instead of dropping it. |
| 816 | const legacySecretsCopied = yield* Effect.forEach( |
| 817 | settingsFileTrusted |
| 818 | ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves |
| 819 | : [], |
| 820 | (move) => |
| 821 | secretStore.get(move.from).pipe( |
| 822 | Effect.flatMap((secret) => |
| 823 | Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value), |
| 824 | ), |
| 825 | Effect.as(true), |
| 826 | Effect.catch(() => |
| 827 | Effect.logWarning("failed to copy a source control secret to its new name", { |
| 828 | secretName: move.to, |
| 829 | }).pipe(Effect.as(false)), |
| 830 | ), |
| 831 | ), |
| 832 | ).pipe(Effect.map((copied) => copied.every(Boolean))); |
| 833 | const hasLegacySourceControl = |
| 834 | settingsFileTrusted && |
| 835 | legacySecretsCopied && |
| 836 | (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined); |
| 837 | const sourceControlMigration = hasLegacySourceControl |
| 838 | ? migrateLegacySourceControlSettings(folded, legacySourceControl) |
| 839 | : { settings: folded, secretMoves: [] }; |
| 840 | // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. |
| 841 | const migrated = settingsFileTrusted |
| 842 | ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings) |
| 843 | : folded; |
| 844 | // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten |
| 845 | // once so the migrated values persist and the old keys disappear. |
| 846 | if ( |
| 847 | migrated !== loaded || |
| 848 | (settingsFileTrusted && legacyProviders !== undefined) || |
| 849 | hasLegacySourceControl |
| 850 | ) { |
| 851 | yield* writeSettingsAtomically(migrated); |
| 852 | for (const move of sourceControlMigration.secretMoves) { |
| 853 | yield* secretStore.remove(move.from).pipe(Effect.ignore); |
| 854 | } |
| 855 | } |
| 856 | return migrated; |
| 857 | }); |
| 858 | |
| 859 | // A failed read is not kept: the next read retries instead of replaying the failure. |
| 860 | const settingsCache = yield* Cache.makeWith<typeof cacheKey, ServerSettings, ServerSettingsError>( |
| 861 | () => loadSettingsFromDisk, |
| 862 | { |
| 863 | capacity: 1, |
| 864 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), |
| 865 | }, |
| 866 | ); |
| 867 | |
| 868 | const getSettingsFromCache = Cache.get(settingsCache, cacheKey); |
| 869 | |
| 870 | const materializeSettingsSecrets = ( |
| 871 | settings: ServerSettings, |
| 872 | ): Effect.Effect<ServerSettings, ServerSettingsError> => |
| 873 | Effect.gen(function* () { |
| 874 | const storedSecrets = new Map<string, string>(); |
| 875 | const pending = new Map<string, SettingsSecret>(); |
| 876 | mapSettingsSecrets(settings, (secret) => { |
| 877 | if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret); |
| 878 | return secret; |
| 879 | }); |
| 880 | for (const secret of pending.values()) { |
| 881 | const value = yield* secretStore.get(secret.secretName).pipe( |
| 882 | Effect.mapError( |
| 883 | (cause) => |
| 884 | new ServerSettingsError({ |
| 885 | settingsPath, |
| 886 | operation: "read-secret", |
| 887 | providerInstanceId: secret.providerInstanceId, |
| 888 | environmentVariable: secret.environmentVariable, |
| 889 | cause, |
| 890 | }), |
| 891 | ), |
| 892 | ); |
| 893 | storedSecrets.set( |
| 894 | secret.secretName, |
| 895 | Option.isSome(value) ? textDecoder.decode(value.value) : "", |
| 896 | ); |
| 897 | } |
| 898 | return mapSettingsSecrets(settings, (secret) => ({ |
| 899 | value: |
| 900 | secret.sensitive && secret.redacted |
| 901 | ? (storedSecrets.get(secret.secretName) ?? "") |
| 902 | : secret.value, |
| 903 | redacted: secret.sensitive && secret.kind === "environment" && secret.redacted, |
| 904 | })); |
| 905 | }); |
| 906 | |
| 907 | const materializeChanges = (changes: Stream.Stream<ServerSettings>) => |
| 908 | changes.pipe( |
| 909 | Stream.mapEffect((settings) => |
| 910 | materializeSettingsSecrets(settings).pipe( |
| 911 | Effect.catch((error: ServerSettingsError) => |
| 912 | Effect.logWarning("failed to materialize settings secrets", { |
| 913 | operation: error.operation, |
| 914 | providerInstanceId: error.providerInstanceId, |
| 915 | environmentVariable: error.environmentVariable, |
| 916 | cause: error.cause, |
| 917 | }).pipe(Effect.as(settings)), |
| 918 | ), |
| 919 | ), |
| 920 | ), |
| 921 | Stream.map(resolveTextGenerationProvider), |
| 922 | ); |
| 923 | |
| 924 | type SecretChange = { |
| 925 | readonly secretName: string; |
| 926 | readonly providerInstanceId?: string; |
| 927 | readonly environmentVariable?: string; |
| 928 | } & ( |
| 929 | | { readonly kind: "write"; readonly value: Uint8Array } |
| 930 | | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" } |
| 931 | ); |
| 932 | |
| 933 | const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) => |
| 934 | Effect.sync(() => { |
| 935 | const previous = new Map<string, SettingsSecret>(); |
| 936 | mapSettingsSecrets(current, (secret) => { |
| 937 | // Duplicate environment names follow the provider's last-value-wins behavior. |
| 938 | previous.set(secret.secretName, secret); |
| 939 | return secret; |
| 940 | }); |
| 941 | const changes: SecretChange[] = []; |
| 942 | const nextSecretNames = new Set<string>(); |
| 943 | const settings = mapSettingsSecrets(next, (secret) => { |
| 944 | const metadata = { |
| 945 | secretName: secret.secretName, |
| 946 | ...(secret.providerInstanceId !== undefined |
| 947 | ? { providerInstanceId: secret.providerInstanceId } |
| 948 | : {}), |
| 949 | ...(secret.environmentVariable !== undefined |
| 950 | ? { environmentVariable: secret.environmentVariable } |
| 951 | : {}), |
| 952 | }; |
| 953 | if (!secret.sensitive) { |
| 954 | changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); |
| 955 | return { value: secret.value, redacted: false }; |
| 956 | } |
| 957 | const old = previous.get(secret.secretName); |
| 958 | const inlineValue = |
| 959 | old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined; |
| 960 | const value = secret.redacted ? inlineValue : secret.value; |
| 961 | if (value === undefined) { |
| 962 | nextSecretNames.add(secret.secretName); |
| 963 | return { value: "", redacted: true }; |
| 964 | } |
| 965 | if (value.length === 0) { |
| 966 | changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); |
| 967 | return { value: "", redacted: false }; |
| 968 | } |
| 969 | nextSecretNames.add(secret.secretName); |
| 970 | changes.push({ ...metadata, kind: "write", value: textEncoder.encode(value) }); |
| 971 | return { value: "", redacted: true }; |
| 972 | }); |
| 973 | for (const secret of previous.values()) { |
| 974 | if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue; |
| 975 | if (changes.some((change) => change.secretName === secret.secretName)) continue; |
| 976 | changes.push({ |
| 977 | kind: "remove", |
| 978 | secretName: secret.secretName, |
| 979 | operation: "remove-stale-secret", |
| 980 | ...(secret.providerInstanceId !== undefined |
| 981 | ? { providerInstanceId: secret.providerInstanceId } |
| 982 | : {}), |
| 983 | ...(secret.environmentVariable !== undefined |
| 984 | ? { environmentVariable: secret.environmentVariable } |
| 985 | : {}), |
| 986 | }); |
| 987 | } |
| 988 | return { settings, changes }; |
| 989 | }); |
| 990 | |
| 991 | const rollbackSettingsSecretWrites = ( |
| 992 | writes: ReadonlyArray<{ |
| 993 | readonly secretName: string; |
| 994 | readonly previousValue: Option.Option<Uint8Array>; |
| 995 | readonly providerInstanceId?: string; |
| 996 | readonly environmentVariable?: string; |
| 997 | }>, |
| 998 | ) => |
| 999 | Effect.forEach( |
| 1000 | writes.toReversed(), |
| 1001 | (write) => |
| 1002 | (Option.isSome(write.previousValue) |
| 1003 | ? secretStore.set(write.secretName, write.previousValue.value) |
| 1004 | : secretStore.remove(write.secretName) |
| 1005 | ).pipe( |
| 1006 | Effect.catch((cause) => |
| 1007 | Effect.logWarning("failed to roll back settings secret", { |
| 1008 | providerInstanceId: write.providerInstanceId, |
| 1009 | environmentVariable: write.environmentVariable, |
| 1010 | cause, |
| 1011 | }), |
| 1012 | ), |
| 1013 | ), |
| 1014 | { discard: true }, |
| 1015 | ); |
| 1016 | |
| 1017 | const applySettingsSecretChanges = (changes: ReadonlyArray<SecretChange>) => { |
| 1018 | const applied: Array<{ |
| 1019 | readonly secretName: string; |
| 1020 | readonly previousValue: Option.Option<Uint8Array>; |
| 1021 | readonly providerInstanceId?: string; |
| 1022 | readonly environmentVariable?: string; |
| 1023 | }> = []; |
| 1024 | const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied)); |
| 1025 | return Effect.forEach( |
| 1026 | changes, |
| 1027 | (change) => |
| 1028 | Effect.gen(function* () { |
| 1029 | const previousValue = yield* secretStore.get(change.secretName).pipe( |
| 1030 | Effect.mapError( |
| 1031 | (cause) => |
| 1032 | new ServerSettingsError({ |
| 1033 | settingsPath, |
| 1034 | operation: "read-secret", |
| 1035 | providerInstanceId: change.providerInstanceId, |
| 1036 | environmentVariable: change.environmentVariable, |
| 1037 | cause, |
| 1038 | }), |
| 1039 | ), |
| 1040 | ); |
| 1041 | // A store operation may mutate before reporting an error (for example chmod after rename). |
| 1042 | applied.push({ ...change, previousValue }); |
| 1043 | yield* ( |
| 1044 | change.kind === "write" |
| 1045 | ? secretStore.set(change.secretName, change.value) |
| 1046 | : secretStore.remove(change.secretName) |
| 1047 | ).pipe( |
| 1048 | Effect.mapError( |
| 1049 | (cause) => |
| 1050 | new ServerSettingsError({ |
| 1051 | settingsPath, |
| 1052 | operation: change.kind === "write" ? "write-secret" : change.operation, |
| 1053 | providerInstanceId: change.providerInstanceId, |
| 1054 | environmentVariable: change.environmentVariable, |
| 1055 | cause, |
| 1056 | }), |
| 1057 | ), |
| 1058 | ); |
| 1059 | }), |
| 1060 | { discard: true }, |
| 1061 | ).pipe( |
| 1062 | Effect.tapError(() => rollback), |
| 1063 | Effect.as(rollback), |
| 1064 | ); |
| 1065 | }; |
| 1066 | |
| 1067 | const updateAndPersistSettings = ( |
| 1068 | update: (current: ServerSettings) => Effect.Effect<ServerSettings, ServerSettingsError>, |
| 1069 | ): Effect.Effect<ServerSettings, ServerSettingsError> => |
| 1070 | writeSemaphore.withPermits(1)( |
| 1071 | Effect.gen(function* () { |
| 1072 | const current = yield* getSettingsFromCache; |
| 1073 | const updated = yield* update(current); |
| 1074 | const persisted = yield* persistSettingsSecrets(current, updated); |
| 1075 | const next = yield* normalizeServerSettings(persisted.settings); |
| 1076 | const materialized = yield* Effect.uninterruptibleMask(() => |
| 1077 | Effect.gen(function* () { |
| 1078 | const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); |
| 1079 | const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); |
| 1080 | if (Exit.isFailure(materializedExit)) { |
| 1081 | yield* rollbackSecretChanges; |
| 1082 | return yield* Effect.failCause(materializedExit.cause); |
| 1083 | } |
| 1084 | const writeExit = yield* Effect.exit(writeSettingsAtomically(next)); |
| 1085 | if (Exit.isFailure(writeExit)) { |
| 1086 | yield* rollbackSecretChanges; |
| 1087 | return yield* Effect.failCause(writeExit.cause); |
| 1088 | } |
| 1089 | return materializedExit.value; |
| 1090 | }), |
| 1091 | ); |
| 1092 | yield* Cache.set(settingsCache, cacheKey, next); |
| 1093 | yield* emitChange(next); |
| 1094 | return resolveTextGenerationProvider(materialized); |
| 1095 | }), |
| 1096 | ); |
| 1097 | |
| 1098 | const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => |
| 1099 | writeSemaphore.withPermits(1)( |
| 1100 | getSettingsFromCache.pipe( |
| 1101 | Effect.flatMap(materializeSettingsSecrets), |
| 1102 | Effect.map(resolveTextGenerationProvider), |
| 1103 | Effect.flatMap(use), |
| 1104 | ), |
| 1105 | ); |
| 1106 | |
| 1107 | const revalidateAndEmit = writeSemaphore.withPermits(1)( |
| 1108 | Effect.gen(function* () { |
| 1109 | yield* Cache.invalidate(settingsCache, cacheKey); |
| 1110 | const settings = yield* getSettingsFromCache; |
| 1111 | yield* emitChange(settings); |
| 1112 | }), |
| 1113 | ); |
| 1114 | |
| 1115 | const watchFileChanges = (filePath: string) => { |
| 1116 | const directory = pathService.dirname(filePath); |
| 1117 | const fileName = pathService.basename(filePath); |
| 1118 | const resolvedFilePath = pathService.resolve(filePath); |
| 1119 | return fs |
| 1120 | .watch(directory) |
| 1121 | .pipe( |
| 1122 | Stream.filter( |
| 1123 | (event) => |
| 1124 | event.path === fileName || |
| 1125 | event.path === filePath || |
| 1126 | pathService.resolve(directory, event.path) === resolvedFilePath, |
| 1127 | ), |
| 1128 | ); |
| 1129 | }; |
| 1130 | |
| 1131 | const startWatcher = Effect.gen(function* () { |
| 1132 | const settingsDir = pathService.dirname(settingsPath); |
| 1133 | |
| 1134 | yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( |
| 1135 | Effect.mapError( |
| 1136 | (cause) => |
| 1137 | new ServerSettingsError({ |
| 1138 | settingsPath, |
| 1139 | operation: "prepare-directory", |
| 1140 | cause, |
| 1141 | }), |
| 1142 | ), |
| 1143 | ); |
| 1144 | |
| 1145 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); |
| 1146 | |
| 1147 | // A symlinked settings file is rewritten in its destination's directory, |
| 1148 | // which a watch on the link's directory never sees. The link is resolved |
| 1149 | // again whenever it changes, so repointing it moves the watch along. |
| 1150 | const watchLinkTarget = Effect.gen(function* () { |
| 1151 | const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( |
| 1152 | Effect.provideService(FileSystem.FileSystem, fs), |
| 1153 | Effect.provideService(Path.Path, pathService), |
| 1154 | ); |
| 1155 | if (linkTargetPath === pathService.resolve(settingsPath)) { |
| 1156 | return Option.none<string>(); |
| 1157 | } |
| 1158 | yield* fs |
| 1159 | .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true }) |
| 1160 | .pipe(Effect.ignore({ log: true })); |
| 1161 | return Option.some(linkTargetPath); |
| 1162 | }).pipe(Effect.orElseSucceed(() => Option.none<string>())); |
| 1163 | |
| 1164 | const initialLinkTarget = yield* watchLinkTarget; |
| 1165 | const linkTargetEvents = Stream.make(initialLinkTarget).pipe( |
| 1166 | Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))), |
| 1167 | Stream.changes, |
| 1168 | Stream.switchMap( |
| 1169 | Option.match({ |
| 1170 | onNone: () => Stream.empty, |
| 1171 | onSome: (linkTargetPath) => |
| 1172 | watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })), |
| 1173 | }), |
| 1174 | ), |
| 1175 | ); |
| 1176 | |
| 1177 | // Debounce watch events so the file is fully written before we read it. |
| 1178 | // Editors emit multiple events per save (truncate, write, rename) and |
| 1179 | // `fs.watch` can fire before the content has been flushed to disk. |
| 1180 | const debouncedSettingsEvents = Stream.merge( |
| 1181 | watchFileChanges(settingsPath), |
| 1182 | linkTargetEvents, |
| 1183 | ).pipe(Stream.debounce(Duration.millis(100))); |
| 1184 | |
| 1185 | yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( |
| 1186 | Effect.ignoreCause({ log: true }), |
| 1187 | Effect.forkIn(watcherScope), |
| 1188 | Effect.asVoid, |
| 1189 | ); |
| 1190 | }); |
| 1191 | |
| 1192 | const start = Effect.gen(function* () { |
| 1193 | const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]); |
| 1194 | if (!shouldStart) { |
| 1195 | return yield* Deferred.await(startedDeferred); |
| 1196 | } |
| 1197 | |
| 1198 | const startup = Effect.gen(function* () { |
| 1199 | yield* startWatcher; |
| 1200 | yield* Cache.invalidate(settingsCache, cacheKey); |
| 1201 | yield* getSettingsFromCache; |
| 1202 | }); |
| 1203 | |
| 1204 | const startupExit = yield* Effect.exit(startup); |
| 1205 | if (startupExit._tag === "Failure") { |
| 1206 | yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); |
| 1207 | return yield* Effect.failCause(startupExit.cause); |
| 1208 | } |
| 1209 | |
| 1210 | yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); |
| 1211 | }); |
| 1212 | |
| 1213 | return { |
| 1214 | start, |
| 1215 | ready: Deferred.await(startedDeferred), |
| 1216 | getSettings: getSettingsFromCache.pipe( |
| 1217 | Effect.flatMap(materializeSettingsSecrets), |
| 1218 | Effect.map(resolveTextGenerationProvider), |
| 1219 | ), |
| 1220 | updateSettings: (patch) => |
| 1221 | updateAndPersistSettings((current) => |
| 1222 | Effect.succeed(applyServerSettingsPatch(current, patch)), |
| 1223 | ), |
| 1224 | updateProviderInstance: (mutation, patch = {}) => |
| 1225 | updateAndPersistSettings((current) => |
| 1226 | Effect.gen(function* () { |
| 1227 | yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath); |
| 1228 | const patched = applyServerSettingsPatch(current, patch); |
| 1229 | return applyProviderInstanceMutation(patched, mutation); |
| 1230 | }), |
| 1231 | ), |
| 1232 | withSettingsSnapshot, |
| 1233 | get streamChanges() { |
| 1234 | return materializeChanges(Stream.fromPubSub(changesPubSub)); |
| 1235 | }, |
| 1236 | get subscribeChanges() { |
| 1237 | return PubSub.subscribe(changesPubSub).pipe( |
| 1238 | Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))), |
| 1239 | ); |
| 1240 | }, |
| 1241 | } satisfies ServerSettingsService["Service"]; |
| 1242 | }); |
| 1243 | |
| 1244 | export const layer = Layer.effect(ServerSettingsService, make); |