574 lines · 19.3 KB · TypeScript
RawDownload
/** * 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.