356 lines · 11.7 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_BY_PROVIDER,  DEFAULT_SERVER_SETTINGS,  type ModelSelection,  type ProviderKind,  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"; 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); const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"]; /** * 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;  if (settings.providers[selection.provider].enabled) {    return settings;  }   const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled);  if (!fallback) {    // No providers enabled — return as-is; callers will report the error.    return settings;  }   return {    ...settings,    textGenerationModelSelection: {      provider: fallback,      model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback],    } as 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 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 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,    );  });   const start = Effect.gen(function* () {    const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]);    if (!shouldStart) {      return yield* Deferred.await(startedDeferred);    }     const startup = Effect.gen(function* () {      yield* startWatcher;      yield* Cache.invalidate(settingsCache, cacheKey);      yield* getSettingsFromCache;    });     const startupExit = yield* Effect.exit(startup);    if (startupExit._tag === "Failure") {      yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie);      return yield* Effect.failCause(startupExit.cause);    }     yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie);  });   return {    start,    ready: Deferred.await(startedDeferred),    getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)),    updateSettings: (patch) =>      writeSemaphore.withPermits(1)(        Effect.gen(function* () {          const current = yield* getSettingsFromCache;          const next = yield* Schema.decodeEffect(ServerSettings)(            applyServerSettingsPatch(current, patch),          ).pipe(            Effect.mapError(              (cause) =>                new ServerSettingsError({                  settingsPath: "<memory>",                  detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,                  cause,                }),            ),          );          yield* writeSettingsAtomically(next);          yield* Cache.set(settingsCache, cacheKey, next);          yield* emitChange(next);          return resolveTextGenerationProvider(next);        }),      ),    get streamChanges() {      return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider));    },  } satisfies ServerSettingsShape;}); export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings);