diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ade3e7c6..fa9253fb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -140,7 +140,10 @@ const PtyAdapterLive = Layer.unwrap( }), ); -const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); +const ServerSettingsLayerLive = ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(SqlitePersistenceLayerLive), +); const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( Layer.provide(ResourceMonitorBinary.layer), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d38a3064..baf6cdbb 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -16,16 +16,45 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); +const recordProviderUsage = ( + providerName: string, + providerInstanceId: string | null = providerName, +) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + status, + last_seen_at + ) + VALUES ( + ${`thread-${providerInstanceId ?? providerName}`}, + ${providerName}, + ${providerInstanceId}, + ${providerName}, + ${"ready"}, + ${"2026-08-31T00:00:00.000Z"} + ) + `; + }); + const makeServerSettingsLayer = () => ServerSettingsModule.layer.pipe( Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge( Layer.fresh( ServerConfig.layerTest(process.cwd(), { @@ -67,6 +96,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); const settingsLayer = ServerSettingsModule.layer.pipe( Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge(configLayer), ); @@ -588,6 +618,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + cursor: { + enabled: false, + }, opencode: { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret-password", @@ -610,6 +643,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + cursor: { + enabled: false, + }, opencode: { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret-password", @@ -628,6 +664,56 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("restores used custom provider instances without enabling legacy providers", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}}}}', + ); + yield* recordProviderUsage("cursor", "cursor_work"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.cursor.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("restores legacy providers from default runtime history", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("keeps explicit provider disables authoritative over runtime history", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providers":{"cursor":{"enabled":false}},"providerInstances":{"cursor_work":{"driver":"cursor","enabled":false,"config":{}}}}', + ); + yield* recordProviderUsage("cursor", "cursor_work"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.cursor.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 2798faf6..e2f475de 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -42,6 +42,7 @@ 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"; @@ -181,6 +182,79 @@ export const layerTest = (overrides: DeepPartial = {}) => 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 + .filter( + ({ providerName, providerInstanceId }) => + providerInstanceId === null || providerInstanceId === providerName, + ) + .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") ? true : settings.providers.cursor.enabled), + }, + grok: { + ...settings.providers.grok, + enabled: + persisted.providers?.grok?.enabled ?? + (usedProviders.has("grok") ? true : settings.providers.grok.enabled), + }, + opencode: { + ...settings.providers.opencode, + enabled: + persisted.providers?.opencode?.enabled ?? + (usedProviders.has("opencode") ? true : settings.providers.opencode.enabled), + }, + }, + providerInstances, + }; +} function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) @@ -216,6 +290,16 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "textGenerationModelSelection", ]); +// Optional providers are enabled by history on load, so their persisted +// defaults must remain distinguishable from an explicit user choice. +const PERSISTED_SERVER_SETTINGS_DEFAULTS = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, + }, +}; + function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; @@ -255,6 +339,7 @@ const make = Effect.gen(function* () { 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(); @@ -289,21 +374,57 @@ const make = Effect.gen(function* () { ); const loadSettingsFromDisk = Effect.gen(function* () { - if (!(yield* readConfigExists)) { - return DEFAULT_SERVER_SETTINGS; + let settings = DEFAULT_SERVER_SETTINGS; + let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + + if (yield* readConfigExists) { + const raw = yield* readRawConfig; + const decoded = decodeServerSettingsJsonExit(raw); + const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); + if (persistedSettings._tag === "Success") { + persisted = persistedSettings.value; + } + if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { + const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + if (failure._tag === "Failure") { + yield* Effect.logWarning("failed to parse settings.json, using defaults", { + path: settingsPath, + issues: Cause.pretty(failure.cause), + cause: failure.cause, + }); + } + } else { + settings = decoded.value; + } } - const raw = yield* readRawConfig; - const decoded = decodeServerSettingsJsonExit(raw); - if (decoded._tag === "Failure") { - yield* Effect.logWarning("failed to parse settings.json, using defaults", { - path: settingsPath, - issues: Cause.pretty(decoded.cause), - cause: decoded.cause, - }); - return DEFAULT_SERVER_SETTINGS; - } - return decoded.value; + const providerHistory = yield* sql<{ + readonly providerName: string; + readonly providerInstanceId: string | null; + }>` + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM projection_thread_sessions + WHERE provider_name IN ('cursor', 'grok', 'opencode') + UNION + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM provider_session_runtime + WHERE provider_name IN ('cursor', 'grok', 'opencode') + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-provider-history", + cause, + }), + ), + ); + + return restoreUsedProviders(settings, persisted, providerHistory); }); const settingsCache = yield* Cache.make({ @@ -479,7 +600,7 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, ); return yield* writeFileStringAtomically({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b1833ba9..76060076 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -163,12 +163,7 @@ const makeBinaryPathSetting = (fallback: string) => Schema.withDecodingDefault(Effect.succeed(fallback)), ); -export type ProviderSettingsFormControl = - | "text" - | "password" - | "textarea" - | "switch" - | "select"; +export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select"; export interface ProviderSettingsFormSelectOption { readonly value: string; @@ -753,6 +748,7 @@ export const ServerSettingsOperation = Schema.Literals([ "normalize", "check-exists", "read-file", + "read-provider-history", "read-secret", "remove-secret", "remove-stale-secret",