982 lines · 35.5 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_TEXT_GENERATION_MODEL,  DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,  DEFAULT_MODEL_BY_PROVIDER,  DEFAULT_SERVER_SETTINGS,  ModelSelection,  ProjectScript,  type ProjectSettingsOverrides,  type ProviderInstanceConfig,  type ProviderInstanceEnvironmentVariable,  type UsageLimitSourceConfig,  ProviderDriverKind,  ProviderInstanceId,  resolveProviderInstanceEnabled,  ServerSettings,  ServerSettingsError,  type ServerSettingsPatch,} from "@t3tools/contracts";import * as Cache from "effect/Cache";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Deferred from "effect/Deferred";import * as Duration from "effect/Duration";import * as Equal from "effect/Equal";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PubSub from "effect/PubSub";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import * as SqlClient from "effect/unstable/sql/SqlClient";import { writeFileStringAtomically } from "./atomicWrite.ts";import * as ServerConfig from "./config.ts";import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";import {  applyServerSettingsPatch,  deriveLegacyProjectOverrides,  isModelSelectionProviderEnabled,} from "@t3tools/shared/serverSettings";import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; const encodeServerSettings = Schema.encodeEffect(ServerSettings);const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder();const textDecoder = new TextDecoder(); /** * Fold the legacy in-config `enabled` flag into the envelope-level * `ProviderInstanceConfig.enabled` and strip it from the config blob, so * explicit provider instances carry exactly one enabled flag. Old settings * files can hold both flags with conflicting values; an explicit false on * either side wins so a user's disable is never silently undone. Runs on * every load and update — the file converges on the next write. */const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => {  let changed = false;  const providerInstances: Record<string, ProviderInstanceConfig> = {};  for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {    const config = instance.config;    // Only fold boolean flags: a malformed `enabled` (e.g. `"false"`) must    // stay in the blob so driver schema validation flags it instead of the    // fold silently repairing the config.    if (      config === null ||      typeof config !== "object" ||      Array.isArray(config) ||      typeof (config as { readonly enabled?: unknown }).enabled !== "boolean"    ) {      providerInstances[instanceId] = instance;      continue;    }    const { enabled: configEnabled, ...restConfig } = config as Record<string, unknown> & {      readonly enabled: boolean;    };    const resolved =      instance.enabled === false || configEnabled === false        ? false        : (instance.enabled ?? configEnabled);    changed = true;    providerInstances[instanceId] = {      ...instance,      enabled: resolved,      config: restConfig,    } satisfies ProviderInstanceConfig;  }  if (!changed) {    return settings;  }  return {    ...settings,    providerInstances: providerInstances as ServerSettings["providerInstances"],  };}; const normalizeServerSettings = (  settings: ServerSettings,): Effect.Effect<ServerSettings, ServerSettingsError> =>  encodeServerSettings(settings).pipe(    Effect.flatMap(decodeServerSettings),    Effect.map(foldProviderInstanceEnabledFlags),    Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })),    Effect.mapError(      (cause) =>        new ServerSettingsError({          settingsPath: "<memory>",          operation: "normalize",          cause,        }),    ),  ); 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")}`;} /** * On disk the hub key is replaced by this marker and the real value lives in * the secret store, mirroring provider environment secrets. A client that * sends the marker back means "keep what you have". */const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; function usageLimitSourceSecretName(sourceId: string): string {  return `usage-limit-source-${Buffer.from(sourceId, "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,    ]),  );  // The hub key is a bearer secret; clients only need to know one is set.  const usageLimitSources = Object.fromEntries(    Object.entries(settings.usageLimitSources).map(([id, source]) => [      id,      {        ...source,        managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "",      },    ]),  );  return { ...settings, providerInstances, usageLimitSources };} export class ServerSettingsService extends Context.Service<  ServerSettingsService,  {    /** 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>;     /**     * Acquire a settings change subscription synchronously in the current     * fiber. Use this before reading a snapshot when changes between the     * snapshot and a lazily started stream must not be lost.     */    readonly subscribeChanges: Effect.Effect<Stream.Stream<ServerSettings>, never, Scope.Scope>;  }>()("t3/serverSettings/ServerSettingsService") {  /** @deprecated Import and use `layerTest` from this module. */  static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) => layerTest(overrides);} const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>  Effect.gen(function* () {    const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } =      overrides;    const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);    const initialSettings = yield* normalizeServerSettings({      ...merged,      ...(automaticGitFetchInterval !== undefined        ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }        : {}),      ...(providerHealthRefreshInterval !== undefined        ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration }        : {}),    });    const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);     return {      start: Effect.void,      ready: Effect.void,      getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)),      updateSettings: (patch) =>        Ref.get(currentSettingsRef).pipe(          Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),          Effect.flatMap(normalizeServerSettings),          Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),          Effect.map(resolveTextGenerationProvider),        ),      streamChanges: Stream.empty,      subscribeChanges: Effect.succeed(Stream.empty),    } satisfies ServerSettingsService["Service"];  }); export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>  Layer.effect(ServerSettingsService, makeTest(overrides)); const ServerSettingsJson = fromLenientJson(ServerSettings);const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);const PersistedOptionalProviderSettings = Schema.Struct({  providers: Schema.optionalKey(    Schema.Struct({      cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),      grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),      opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),    }),  ),});const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit(  fromLenientJson(PersistedOptionalProviderSettings),); function restoreUsedProviders(  settings: ServerSettings,  persisted: typeof PersistedOptionalProviderSettings.Type,  providerHistory: ReadonlyArray<{    readonly providerName: string;    readonly providerInstanceId: string | null;  }>,): ServerSettings {  const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName));  const usedProviderInstances = new Set(    providerHistory.map(      ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName,    ),  );  const providerInstances = Object.fromEntries(    Object.entries(settings.providerInstances).map(([instanceId, instance]) => [      instanceId,      instance.enabled === undefined &&      (instance.driver === "cursor" ||        instance.driver === "grok" ||        instance.driver === "opencode") &&      usedProviderInstances.has(instanceId)        ? { ...instance, enabled: true }        : instance,    ]),  );   return {    ...settings,    providers: {      ...settings.providers,      cursor: {        ...settings.providers.cursor,        enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"),      },      grok: {        ...settings.providers.grok,        enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"),      },      opencode: {        ...settings.providers.opencode,        enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"),      },    },    providerInstances,  };} function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {  return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection)    ? settings    : fallbackTextGenerationProvider(settings);} function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {  // Same precedence as isModelSelectionProviderEnabled: an explicit provider  // instance wins over the legacy providers map, which decodes to defaults  // (codex enabled) when the Providers UI has only written providerInstances.  const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => {    const instance = settings.providerInstances[ProviderInstanceId.make(driver)];    return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance);  });  const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined;  if (!fallback) {    return settings;  }   return {    ...settings,    textGenerationModelSelection: {      instanceId: ProviderInstanceId.make(fallback),      model:        DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??        DEFAULT_MODEL_BY_PROVIDER[fallback] ??        DEFAULT_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([  "backgroundActivity",  "automaticGitFetchInterval",  "providerHealthRefreshInterval",  "sourceControlWriterModelSelection",  "textGenerationModelSelection",]); // Preserve both enabled states because provider history cannot recover a new opt-in.const PERSISTED_SERVER_SETTINGS_DEFAULTS = {  ...DEFAULT_SERVER_SETTINGS,  providers: {    ...DEFAULT_SERVER_SETTINGS.providers,    cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined },    grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined },    opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined },  },}; 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 decodeProjectScriptsJson = Schema.decodeUnknownOption(  Schema.fromJsonString(Schema.Array(ProjectScript)),);const decodeModelSelectionJson = Schema.decodeUnknownOption(  Schema.fromJsonString(Schema.NullOr(ModelSelection)),); interface LegacyProjectSettingsRow {  readonly projectId: string;  readonly defaultModelSelection: string | null;  readonly defaultThreadEnvMode: string | null;  readonly autoPull: number;  readonly scripts: string;} /** * One-time fold of the legacy per-project fields into `projectSettingsOverrides`: * the three `project*Overrides` maps and the settings columns on the project * aggregate. Keys already present in the generic record win. Marked with * `projectSettingsFolded` so a later reset in the UI survives restarts. */function foldLegacyProjectSettings(  settings: ServerSettings,  rows: ReadonlyArray<LegacyProjectSettingsRow>,): ServerSettings {  if (settings.projectSettingsFolded) return settings;  // Nothing to fold yet (fresh install): leave the marker off so the file  // stays sparse, and check again on the next load.  if (    rows.length === 0 &&    Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 &&    Object.keys(settings.projectAutoPullOverrides).length === 0 &&    Object.keys(settings.projectScriptOverrides).length === 0  ) {    return settings;  }  const entries: Record<string, ProjectSettingsOverrides> = {    ...settings.projectSettingsOverrides,  };  const set = <K extends keyof ProjectSettingsOverrides>(    projectId: string,    key: K,    value: ProjectSettingsOverrides[K] | undefined,  ) => {    if (value === undefined) return;    const entry = entries[projectId] ?? {};    if (Object.hasOwn(entry, key)) return;    entries[projectId] = { ...entry, [key]: value };  };  for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) {    set(projectId, "enableAgentBrowserAccess", value);  }  for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) {    set(projectId, "defaultAutoPull", value);  }  for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) {    // A stored null meant "reset to machine defaults", which is now plain inheritance.    if (value !== null) set(projectId, "defaultProjectScripts", value);  }  for (const row of rows) {    const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null");    if (Option.isSome(model) && model.value !== null) {      set(row.projectId, "defaultModelSelection", model.value);    }    if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") {      set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode);    }    if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true);    const scripts = decodeProjectScriptsJson(row.scripts);    if (Option.isSome(scripts) && scripts.value.length > 0) {      set(row.projectId, "defaultProjectScripts", scripts.value);    }  }  const projectSettingsOverrides = Object.fromEntries(    Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0),  );  return {    ...settings,    projectSettingsOverrides,    projectSettingsFolded: true,    ...deriveLegacyProjectOverrides({ projectSettingsOverrides }),  };} const make = Effect.gen(function* () {  const { settingsPath } = yield* ServerConfig.ServerConfig;  const fs = yield* FileSystem.FileSystem;  const pathService = yield* Path.Path;  const secretStore = yield* ServerSecretStore.ServerSecretStore;  const sql = yield* SqlClient.SqlClient;  const writeSemaphore = yield* Semaphore.make(1);  const cacheKey = "settings" as const;  const changesPubSub = yield* PubSub.unbounded<ServerSettings>();  const startedRef = yield* Ref.make(false);

Lines 1–500 of 982.