/** * ServerSettings - Server-authoritative settings service. * * Owns persistence, validation, and change notification of settings that affect * server-side behavior (binary paths, streaming mode, env mode, custom models, * text generation model selection). * * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + * Semaphore + FileSystem.watch for concurrency and external edit detection. * * @module ServerSettings */import { DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, isProviderDriverKind, type ModelSelection, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, ProviderDriverKind, ProviderInstanceId, ServerSettings, ServerSettingsError, type ServerSettingsPatch,} from "@t3tools/contracts";import { Cache, Deferred, Duration, Effect, Exit, FileSystem, Layer, Path, Equal, PubSub, Ref, Schema, SchemaIssue, Scope, Context, Stream, Cause,} from "effect";import * as Semaphore from "effect/Semaphore";import { writeFileStringAtomically } from "./atomicWrite.ts";import { ServerConfig } from "./config.ts";import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";import { fromLenientJson } from "@t3tools/shared/schemaJson";import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; const textEncoder = new TextEncoder();const textDecoder = new TextDecoder(); function providerEnvironmentSecretName(input: { readonly instanceId: string; readonly name: string;}): string { return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`;} function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable,): ProviderInstanceEnvironmentVariable { if (!variable.sensitive) { const { valueRedacted: _omit, ...rest } = variable; return rest; } return { ...variable, value: "", ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}), };} export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ instanceId, instance.environment ? { ...instance, environment: instance.environment.map(redactProviderEnvironmentVariable), } : instance, ]), ); return { ...settings, providerInstances };} export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ readonly start: Effect.Effect<void, ServerSettingsError>; /** Await settings runtime readiness. */ readonly ready: Effect.Effect<void, ServerSettingsError>; /** Read the current settings. */ readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>; /** Patch settings and persist. Returns the new full settings object. */ readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect<ServerSettings, ServerSettingsError>; /** Stream of settings change events. */ readonly streamChanges: Stream.Stream<ServerSettings>;} export class ServerSettingsService extends Context.Service< ServerSettingsService, ServerSettingsShape>()("t3/serverSettings/ServerSettingsService") { static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) => Layer.effect( ServerSettingsService, Effect.gen(function* () { const currentSettingsRef = yield* Ref.make<ServerSettings>( deepMerge(DEFAULT_SERVER_SETTINGS, overrides), ); return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( Effect.flatMap((currentSettings) => Schema.decodeEffect(ServerSettings)( applyServerSettingsPatch(currentSettings, patch), ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath: "<memory>", detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, cause, }), ), ), ), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, } satisfies ServerSettingsShape; }), );} const ServerSettingsJson = fromLenientJson(ServerSettings); type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; const getLegacyProviderSettings = ( settings: ServerSettings, provider: ProviderDriverKind,): LegacyProviderSettings | undefined => (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. * If the selected provider is disabled, fall back to the first enabled * provider with its default model. This is applied at read-time so the * persisted preference is preserved for when a provider is re-enabled. */function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; const instanceConfig = settings.providerInstances[selection.instanceId]; if (instanceConfig !== undefined) { return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); } if ( isProviderDriverKind(selection.instanceId) && getLegacyProviderSettings(settings, selection.instanceId)?.enabled ) { return settings; } return fallbackTextGenerationProvider(settings);} function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { return settings; } return { ...settings, textGenerationModelSelection: { instanceId: ProviderInstanceId.make(fallback), model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_GIT_TEXT_GENERATION_MODEL, } satisfies ModelSelection, };} // Values under these keys are compared as a whole — never stripped field-by-field.const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]); function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; } if ( current !== null && defaults !== null && typeof current === "object" && typeof defaults === "object" ) { const currentRecord = current as Record<string, unknown>; const defaultsRecord = defaults as Record<string, unknown>; const next: Record<string, unknown> = {}; for (const key of Object.keys(currentRecord)) { if (ATOMIC_SETTINGS_KEYS.has(key)) { if (!Equal.equals(currentRecord[key], defaultsRecord[key])) { next[key] = currentRecord[key]; } } else { const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]); if (stripped !== undefined) { next[key] = stripped; } } } return Object.keys(next).length > 0 ? next : undefined; } return Object.is(current, defaults) ? undefined : current;} const makeServerSettings = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig; const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const secretStore = yield* ServerSecretStore; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make<void, ServerSettingsError>(); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); const emitChange = (settings: ServerSettings) => PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); const readConfigExists = fs.exists(settingsPath).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to check settings file existence", cause, }), ), ); const readRawConfig = fs.readFileString(settingsPath).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to read settings file", cause, }), ), ); const loadSettingsFromDisk = Effect.gen(function* () { if (!(yield* readConfigExists)) { return DEFAULT_SERVER_SETTINGS; } const raw = yield* readRawConfig; const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); if (decoded._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(decoded.cause), }); return DEFAULT_SERVER_SETTINGS; } return decoded.value; }); const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({ capacity: 1, lookup: () => loadSettingsFromDisk, }); const getSettingsFromCache = Cache.get(settingsCache, cacheKey); const toSettingsError = (detail: string, cause: unknown) => new ServerSettingsError({ settingsPath, detail, cause, }); const materializeProviderEnvironmentSecrets = ( settings: ServerSettings, ): Effect.Effect<ServerSettings, ServerSettingsError> => Effect.gen(function* () { const providerInstances: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances, }; for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { if (!instance.environment) continue; const environment: ProviderInstanceEnvironmentVariable[] = []; for (const variable of instance.environment) { if (!variable.sensitive || !variable.valueRedacted) { environment.push(variable); continue; } const secret = yield* secretStore .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) .pipe( Effect.mapError((cause) => toSettingsError( `failed to read sensitive environment variable ${variable.name}`, cause, ), ), ); environment.push({ ...variable, value: secret ? textDecoder.decode(secret) : "", }); } providerInstances[instanceId] = { ...instance, environment, } satisfies ProviderInstanceConfig; } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], }; }); const persistProviderEnvironmentSecrets = ( current: ServerSettings, next: ServerSettings, ): Effect.Effect<ServerSettings, ServerSettingsError> => Effect.gen(function* () { const providerInstances: Record<string, ProviderInstanceConfig> = { ...next.providerInstances, }; const nextSecretKeys = new Set<string>(); for (const [instanceId, instance] of Object.entries(next.providerInstances)) { if (!instance.environment) continue; const environment: ProviderInstanceEnvironmentVariable[] = []; for (const variable of instance.environment) { const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (!variable.sensitive) { yield* secretStore .remove(secretName) .pipe( Effect.mapError((cause) => toSettingsError(`failed to remove environment secret ${variable.name}`, cause), ), ); environment.push(redactProviderEnvironmentVariable(variable)); continue; } nextSecretKeys.add(secretName); if (!variable.valueRedacted) { if (variable.value.length > 0) { yield* secretStore .set(secretName, textEncoder.encode(variable.value)) .pipe( Effect.mapError((cause) => toSettingsError(`failed to persist environment secret ${variable.name}`, cause), ), ); environment.push({ ...variable, value: "", valueRedacted: true }); } else { yield* secretStore .remove(secretName) .pipe( Effect.mapError((cause) => toSettingsError(`failed to remove environment secret ${variable.name}`, cause), ), ); const { valueRedacted: _omit, ...rest } = variable; environment.push(rest); } continue; } environment.push(redactProviderEnvironmentVariable(variable)); } providerInstances[instanceId] = { ...instance, environment, } satisfies ProviderInstanceConfig; } for (const [instanceId, instance] of Object.entries(current.providerInstances)) { for (const variable of instance.environment ?? []) { if (!variable.sensitive) continue; const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; yield* secretStore .remove(secretName) .pipe( Effect.mapError((cause) => toSettingsError( `failed to remove stale environment secret ${variable.name}`, cause, ), ), ); } } return { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], }; }); const writeSettingsAtomically = (settings: ServerSettings) => { const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; return writeFileStringAtomically({ filePath: settingsPath, contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathService), Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to write settings file", cause, }), ), ); }; const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); const settings = yield* getSettingsFromCache; yield* emitChange(settings); }), ); const startWatcher = Effect.gen(function* () { const settingsDir = pathService.dirname(settingsPath); const settingsFile = pathService.basename(settingsPath); const settingsPathResolved = pathService.resolve(settingsPath); yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to prepare settings directory", cause, }), ), ); const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and // `fs.watch` can fire before the content has been flushed to disk. const debouncedSettingsEvents = fs.watch(settingsDir).pipe( Stream.filter((event) => { return ( event.path === settingsFile || event.path === settingsPath || pathService.resolve(settingsDir, event.path) === settingsPathResolved ); }), Stream.debounce(Duration.millis(100)), ); yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid, ); });Lines 1–500 of 574. Load more
/** * ServerSettings - Server-authoritative settings service. * * Owns persistence, validation, and change notification of settings that affect * server-side behavior (binary paths, streaming mode, env mode, custom models, * text generation model selection). * * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + * Semaphore + FileSystem.watch for concurrency and external edit detection. * * @module ServerSettings */ import { DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, isProviderDriverKind, type ModelSelection, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, ProviderDriverKind, ProviderInstanceId, ServerSettings, ServerSettingsError, type ServerSettingsPatch, } from "@t3tools/contracts"; import { Cache, Deferred, Duration, Effect, Exit, FileSystem, Layer, Path, Equal, PubSub, Ref, Schema, SchemaIssue, Scope, Context, Stream, Cause, } from "effect"; import * as Semaphore from "effect/Semaphore"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); function providerEnvironmentSecretName(input: { readonly instanceId: string; readonly name: string; }): string { return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; } function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { if (!variable.sensitive) { const { valueRedacted: _omit, ...rest } = variable; return rest; } return { ...variable, value: "", ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}), }; } export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ instanceId, instance.environment ? { ...instance, environment: instance.environment.map(redactProviderEnvironmentVariable), } : instance, ]), ); return { ...settings, providerInstances }; } export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ readonly start: Effect.Effect<void, ServerSettingsError>; /** Await settings runtime readiness. */ readonly ready: Effect.Effect<void, ServerSettingsError>; /** Read the current settings. */ readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>; /** Patch settings and persist. Returns the new full settings object. */ readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect<ServerSettings, ServerSettingsError>; /** Stream of settings change events. */ readonly streamChanges: Stream.Stream<ServerSettings>; } export class ServerSettingsService extends Context.Service< ServerSettingsService, ServerSettingsShape >()("t3/serverSettings/ServerSettingsService") { static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) => Layer.effect( ServerSettingsService, Effect.gen(function* () { const currentSettingsRef = yield* Ref.make<ServerSettings>( deepMerge(DEFAULT_SERVER_SETTINGS, overrides), ); return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( Effect.flatMap((currentSettings) => Schema.decodeEffect(ServerSettings)( applyServerSettingsPatch(currentSettings, patch), ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath: "<memory>", detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, cause, }), ), ), ), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, } satisfies ServerSettingsShape; }), ); } const ServerSettingsJson = fromLenientJson(ServerSettings); type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; const getLegacyProviderSettings = ( settings: ServerSettings, provider: ProviderDriverKind, ): LegacyProviderSettings | undefined => (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. * If the selected provider is disabled, fall back to the first enabled * provider with its default model. This is applied at read-time so the * persisted preference is preserved for when a provider is re-enabled. */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; const instanceConfig = settings.providerInstances[selection.instanceId]; if (instanceConfig !== undefined) { return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); } if ( isProviderDriverKind(selection.instanceId) && getLegacyProviderSettings(settings, selection.instanceId)?.enabled ) { return settings; } return fallbackTextGenerationProvider(settings); } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { return settings; } return { ...settings, textGenerationModelSelection: { instanceId: ProviderInstanceId.make(fallback), model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_GIT_TEXT_GENERATION_MODEL, } satisfies ModelSelection, }; } // Values under these keys are compared as a whole — never stripped field-by-field. const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]); function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; } if ( current !== null && defaults !== null && typeof current === "object" && typeof defaults === "object" ) { const currentRecord = current as Record<string, unknown>; const defaultsRecord = defaults as Record<string, unknown>; const next: Record<string, unknown> = {}; for (const key of Object.keys(currentRecord)) { if (ATOMIC_SETTINGS_KEYS.has(key)) { if (!Equal.equals(currentRecord[key], defaultsRecord[key])) { next[key] = currentRecord[key]; } } else { const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]); if (stripped !== undefined) { next[key] = stripped; } } } return Object.keys(next).length > 0 ? next : undefined; } return Object.is(current, defaults) ? undefined : current; } const makeServerSettings = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig; const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const secretStore = yield* ServerSecretStore; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make<void, ServerSettingsError>(); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); const emitChange = (settings: ServerSettings) => PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); const readConfigExists = fs.exists(settingsPath).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to check settings file existence", cause, }), ), ); const readRawConfig = fs.readFileString(settingsPath).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to read settings file", cause, }), ), ); const loadSettingsFromDisk = Effect.gen(function* () { if (!(yield* readConfigExists)) { return DEFAULT_SERVER_SETTINGS; } const raw = yield* readRawConfig; const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); if (decoded._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(decoded.cause), }); return DEFAULT_SERVER_SETTINGS; } return decoded.value; }); const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({ capacity: 1, lookup: () => loadSettingsFromDisk, }); const getSettingsFromCache = Cache.get(settingsCache, cacheKey); const toSettingsError = (detail: string, cause: unknown) => new ServerSettingsError({ settingsPath, detail, cause, }); const materializeProviderEnvironmentSecrets = ( settings: ServerSettings, ): Effect.Effect<ServerSettings, ServerSettingsError> => Effect.gen(function* () { const providerInstances: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances, }; for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { if (!instance.environment) continue; const environment: ProviderInstanceEnvironmentVariable[] = []; for (const variable of instance.environment) { if (!variable.sensitive || !variable.valueRedacted) { environment.push(variable); continue; } const secret = yield* secretStore .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) .pipe( Effect.mapError((cause) => toSettingsError( `failed to read sensitive environment variable ${variable.name}`, cause, ), ), ); environment.push({ ...variable, value: secret ? textDecoder.decode(secret) : "", }); } providerInstances[instanceId] = { ...instance, environment, } satisfies ProviderInstanceConfig; } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], }; }); const persistProviderEnvironmentSecrets = ( current: ServerSettings, next: ServerSettings, ): Effect.Effect<ServerSettings, ServerSettingsError> => Effect.gen(function* () { const providerInstances: Record<string, ProviderInstanceConfig> = { ...next.providerInstances, }; const nextSecretKeys = new Set<string>(); for (const [instanceId, instance] of Object.entries(next.providerInstances)) { if (!instance.environment) continue; const environment: ProviderInstanceEnvironmentVariable[] = []; for (const variable of instance.environment) { const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (!variable.sensitive) { yield* secretStore .remove(secretName) .pipe( Effect.mapError((cause) => toSettingsError(`failed to remove environment secret ${variable.name}`, cause), ), ); environment.push(redactProviderEnvironmentVariable(variable)); continue; } nextSecretKeys.add(secretName); if (!variable.valueRedacted) { if (variable.value.length > 0) { yield* secretStore .set(secretName, textEncoder.encode(variable.value)) .pipe( Effect.mapError((cause) => toSettingsError(`failed to persist environment secret ${variable.name}`, cause), ), ); environment.push({ ...variable, value: "", valueRedacted: true }); } else { yield* secretStore .remove(secretName) .pipe( Effect.mapError((cause) => toSettingsError(`failed to remove environment secret ${variable.name}`, cause), ), ); const { valueRedacted: _omit, ...rest } = variable; environment.push(rest); } continue; } environment.push(redactProviderEnvironmentVariable(variable)); } providerInstances[instanceId] = { ...instance, environment, } satisfies ProviderInstanceConfig; } for (const [instanceId, instance] of Object.entries(current.providerInstances)) { for (const variable of instance.environment ?? []) { if (!variable.sensitive) continue; const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; yield* secretStore .remove(secretName) .pipe( Effect.mapError((cause) => toSettingsError( `failed to remove stale environment secret ${variable.name}`, cause, ), ), ); } } return { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], }; }); const writeSettingsAtomically = (settings: ServerSettings) => { const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; return writeFileStringAtomically({ filePath: settingsPath, contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathService), Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to write settings file", cause, }), ), ); }; const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); const settings = yield* getSettingsFromCache; yield* emitChange(settings); }), ); const startWatcher = Effect.gen(function* () { const settingsDir = pathService.dirname(settingsPath); const settingsFile = pathService.basename(settingsPath); const settingsPathResolved = pathService.resolve(settingsPath); yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to prepare settings directory", cause, }), ), ); const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and // `fs.watch` can fire before the content has been flushed to disk. const debouncedSettingsEvents = fs.watch(settingsDir).pipe( Stream.filter((event) => { return ( event.path === settingsFile || event.path === settingsPath || pathService.resolve(settingsDir, event.path) === settingsPathResolved ); }), Stream.debounce(Duration.millis(100)), ); yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid, ); });