import { DEFAULT_SERVER_SETTINGS, ModelSelection, ProjectId, ProjectScript, ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceMutation, defaultInstanceIdForDriver, isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, ResponseStreamingMode, ServerSettings, ServerSettingsError, type ServerSettingsPatch, globalIdleCompaction, withIdleCompaction, } 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 NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; 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 SchemaTransformation from "effect/SchemaTransformation"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, deriveLegacyProjectOverrides, fallbackTextGenerationModelSelection, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import { readSecretFieldValues, sourceControlHostSecretName, } from "./sourceControl/sourceControlHostSecrets.ts"; import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; export { resolveReviewGuideModelSelection, 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(); // Conflicting flags resolve to false to preserve user disables. const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => { let changed = false; const providerInstances: Record = {}; for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { const config = instance.config; // Leave malformed flags for driver schema validation rather than silently repairing them. 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 & { 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 => encodeServerSettings(settings).pipe( Effect.flatMap(decodeServerSettings), Effect.map(foldProviderInstanceEnabledFlags), Effect.map((next) => withIdleCompaction(next, globalIdleCompaction(next))), Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), Effect.mapError( (cause) => new ServerSettingsError({ settingsPath: "", operation: "normalize", cause, }), ), ); export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { return mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); } export function applyProviderInstanceMutation( settings: ServerSettings, mutation: ProviderInstanceMutation, ): ServerSettings { const providerInstances = { ...settings.providerInstances }; if (mutation.operation === "upsert" || mutation.operation === "create") { providerInstances[mutation.instanceId] = mutation.instance; } else { delete providerInstances[mutation.instanceId]; } return { ...settings, providerInstances }; } function ensureProviderInstanceMutationAllowed( settings: ServerSettings, mutation: ProviderInstanceMutation, settingsPath: string, ): Effect.Effect { if ( mutation.operation === "create" && settings.providerInstances[mutation.instanceId] !== undefined ) { return Effect.fail( new ServerSettingsError({ settingsPath, operation: "create-provider-instance", providerInstanceId: mutation.instanceId, }), ); } return Effect.void; } export class ServerSettingsService extends Context.Service< ServerSettingsService, { readonly start: Effect.Effect; readonly ready: Effect.Effect; readonly getSettings: Effect.Effect; readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect; readonly updateProviderInstance: ( mutation: ProviderInstanceMutation, patch?: ServerSettingsPatch, ) => Effect.Effect; readonly withSettingsSnapshot: ( use: (settings: ServerSettings) => Effect.Effect, ) => Effect.Effect; readonly streamChanges: Stream.Stream; /** Subscribe before reading a snapshot to retain changes before lazy stream startup. */ readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { static readonly layerTest = (overrides: DeepPartial = {}) => layerTest(overrides); } const makeTest = Effect.fn("serverSettings.makeTest")(function* ( overrides: DeepPartial = {}, ) { 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(initialSettings); const writeSemaphore = yield* Semaphore.make(1); const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)); const updateTestSettings = ( update: (current: ServerSettings) => Effect.Effect, ): Effect.Effect => writeSemaphore.withPermits(1)( Ref.get(currentSettingsRef).pipe( Effect.flatMap(update), Effect.flatMap(normalizeServerSettings), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), Effect.map(resolveTextGenerationProvider), ), ); return { start: Effect.void, ready: Effect.void, getSettings, updateSettings: (patch) => updateTestSettings((currentSettings) => Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), ), updateProviderInstance: (mutation, patch = {}) => updateTestSettings((currentSettings) => Effect.gen(function* () { yield* ensureProviderInstanceMutationAllowed(currentSettings, mutation, "test settings"); const patched = applyServerSettingsPatch(currentSettings, patch); return applyProviderInstanceMutation(patched, mutation); }), ), withSettingsSnapshot: (use) => writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))), streamChanges: Stream.empty, subscribeChanges: Effect.succeed(Stream.empty), } satisfies ServerSettingsService["Service"]; }); export const layerTest = (overrides: DeepPartial = {}) => Layer.effect(ServerSettingsService, makeTest(overrides)); // Accept retired token delivery only on load so one old value cannot reset all settings. const PersistedResponseStreamingMode = Schema.Union([ ResponseStreamingMode, Schema.Literal("token"), ]).pipe( Schema.decodeTo( ResponseStreamingMode, SchemaTransformation.transform({ decode: (mode) => (mode === "token" ? "paragraph" : mode), encode: (mode) => mode, }), ), ); const ServerSettingsJson = fromLenientJson( Schema.Struct({ ...ServerSettings.fields, responseStreamingMode: PersistedResponseStreamingMode.pipe( Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), ), projectSettingsOverrides: Schema.Record( ProjectId, Schema.Struct({ ...ProjectSettingsOverrides.fields, responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), }), ); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), }), ); const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); const LEGACY_BITBUCKET_SECRET_NAMES: Readonly> = { accessToken: "bitbucket-access-token", apiToken: "bitbucket-api-token", }; const legacyGitHubTokenSecretName = (host: string) => `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`; function migrateLegacySourceControlSettings( settings: ServerSettings, legacy: { readonly bitbucket?: Readonly> | undefined; readonly github?: Readonly> | undefined; }, ): { readonly settings: ServerSettings; readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>; } { const sourceControlHosts = { ...settings.sourceControlHosts }; const secretMoves: Array<{ readonly from: string; readonly to: string }> = []; if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) { sourceControlHosts.bitbucket = legacy.bitbucket; for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) { if (legacy.bitbucket[field] !== SECRET_REDACTED) continue; secretMoves.push({ from, to: sourceControlHostSecretName({ kind: "bitbucket", field, serverHost: null }), }); } } if (legacy.github !== undefined && sourceControlHosts.github === undefined) { sourceControlHosts.github = legacy.github; for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) { if (serverHost === null || value !== SECRET_REDACTED) continue; secretMoves.push({ from: legacyGitHubTokenSecretName(serverHost), to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }), }); } } return { settings: { ...settings, sourceControlHosts }, secretMoves }; } const HISTORY_RESTORED_DRIVERS: ReadonlySet = new Set( ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)), ); function migrateLegacyProviderSettings( settings: ServerSettings, legacyProviders: Readonly>, 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: Record = {}; for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array< [ProviderInstanceId, ProviderInstanceConfig] >) { providerInstances[instanceId] = instance.enabled === undefined && HISTORY_RESTORED_DRIVERS.has(instance.driver) && usedProviderInstances.has(instanceId) ? { ...instance, enabled: true } : instance; } const legacyEntries = new Map(Object.entries(legacyProviders)); for (const driver of HISTORY_RESTORED_DRIVERS) { if (!legacyEntries.has(driver)) legacyEntries.set(driver, {}); } for (const [kind, blob] of legacyEntries) { if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue; if (Array.isArray(blob)) continue; const driver = kind; const instanceId = defaultInstanceIdForDriver(driver); if (Object.hasOwn(providerInstances, instanceId)) continue; const { enabled: rawEnabled, ...config } = blob as Record; const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined; const enabled = explicitEnabled ?? (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined); const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} }); if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) { continue; } providerInstances[instanceId] = { driver, ...(enabled === undefined ? {} : { enabled }), config, } satisfies ProviderInstanceConfig; } return { ...settings, providerInstances, }; } const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); function selectionSupportsTextGeneration( settings: ServerSettings, selection: ModelSelection, ): boolean { return settings.providerInstances[selection.instanceId]?.driver !== ACP_REGISTRY_DRIVER; } function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) && selectionSupportsTextGeneration(settings, settings.textGenerationModelSelection) ? settings : fallbackTextGenerationProvider(settings); } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { const fallback = fallbackTextGenerationModelSelection( settings.providerInstances, (instanceId, driver) => resolveProviderInstanceEnabled( settings.providerInstances[instanceId] ?? { driver, config: {} }, ), { defaultModelSelection: settings.defaultModelSelection }, ); return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; } const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "backgroundActivity", "automaticGitFetchInterval", "providerHealthRefreshInterval", "sourceControlWriterModelSelection", "reviewGuideModelSelection", "textGenerationModelSelection", "fallbackModelSelection", "pullRequestMergeMethod", ]); 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; const defaultsRecord = defaults as Record; const next: Record = {}; 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; } // projectSettingsFolded prevents restarts from undoing later UI resets. function foldLegacyProjectSettings( settings: ServerSettings, rows: ReadonlyArray, ): ServerSettings { if (settings.projectSettingsFolded) return settings; 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 = { ...settings.projectSettingsOverrides, }; const set = ( 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); } // A stored null resets to machine defaults, so the project's aggregate scripts must not resurface. const resetScripts = new Set(); for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { if (value === null) resetScripts.add(projectId); else 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 && !resetScripts.has(row.projectId)) { 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(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make(); 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, operation: "check-exists", cause, }), ), ); const readRawConfig = fs.readFileString(settingsPath).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "read-file", cause, }), ), ); const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, ); return yield* writeFileStringAtomically({ filePath: settingsPath, contents: `${sparseSettingsJson}\n`, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathService), ); }, Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "write-file", cause, }), ), ); const moveInlineSettingsSecrets = Effect.fn("serverSettings.moveInlineSettingsSecrets")( function* (settings: ServerSettings) { const pending: SettingsSecret[] = []; mapSettingsSecrets(settings, (secret) => { if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) { pending.push(secret); } return secret; }); const moved = new Set(); for (const secret of pending) { const stored = yield* secretStore .set(secret.secretName, textEncoder.encode(secret.value)) .pipe( Effect.as(true), Effect.catch(() => Effect.logWarning("failed to move a settings secret into the secret store").pipe( Effect.as(false), ), ), ); if (stored) moved.add(secret.secretName); } if (moved.size === 0) return settings; return mapSettingsSecrets(settings, (secret) => moved.has(secret.secretName) ? { value: "", redacted: true } : secret, ); }, ); const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let legacyProviders: Readonly> | undefined; let legacySourceControl: Parameters[1] = {}; let parseFailure = Option.none(); if (yield* readConfigExists) { const raw = yield* readRawConfig; const decoded = decodeServerSettingsJsonExit(raw); const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); if (legacySettings._tag === "Success") { legacyProviders = legacySettings.value.providers; legacySourceControl = { bitbucket: legacySettings.value.bitbucket, github: legacySettings.value.github, }; } if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; // The file may hold an opt-out we can no longer read; never resume threads on a guess. settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false }; if (failure._tag === "Failure") { parseFailure = Option.some(Cause.squash(failure.cause)); yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(failure.cause), cause: failure.cause, }); } } else { settings = decoded.value; } } // Settings saved by a server that kept the mode in `compactIdleThreads` move // it to `idleCompaction`, so that switch goes to clients as a boolean. The // defaults gain the field too, which tells clients this server reads modes. settings = withIdleCompaction(settings, globalIdleCompaction(settings)); // A file that failed to decode must stay on disk for the user to repair. const settingsFileTrusted = Option.isNone(parseFailure); 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( NodeSqliteClient.readOnly, Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "read-provider-history", cause, }), ), ); const legacyProjectRows = settings.projectSettingsFolded || !settingsFileTrusted ? [] : yield* sql` SELECT project_id AS "projectId", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", scripts_json AS "scripts" FROM projection_projects WHERE deleted_at IS NULL `.pipe( NodeSqliteClient.readOnly, Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "read-project-settings", cause, }), ), ); const loaded = foldProviderInstanceEnabledFlags( legacyProviders === undefined ? settings : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory), ); const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; // Remove retired keys only after copying secrets and writing the new file; failures retry on next load. const legacySecretsCopied = yield* Effect.forEach( settingsFileTrusted ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves : [], (move) => secretStore.get(move.from).pipe( Effect.flatMap((secret) => Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value), ), Effect.as(true), Effect.catch(() => Effect.logWarning("failed to copy a source control secret to its new name", { secretName: move.to, }).pipe(Effect.as(false)), ), ), ).pipe(Effect.map((copied) => copied.every(Boolean))); const hasLegacySourceControl = settingsFileTrusted && legacySecretsCopied && (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined); const sourceControlMigration = hasLegacySourceControl ? migrateLegacySourceControlSettings(folded, legacySourceControl) : { settings: folded, secretMoves: [] }; const migrated = settingsFileTrusted ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings) : folded; if ( migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined) || hasLegacySourceControl ) { yield* writeSettingsAtomically(migrated); for (const move of sourceControlMigration.secretMoves) { yield* secretStore.remove(move.from).pipe(Effect.ignore); } } return { settings: migrated, parseFailure }; }); const settingsCache = yield* Cache.makeWith((_key: typeof cacheKey) => loadSettingsFromDisk, { capacity: 1, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), }); const getSettingsFromCache = Cache.get(settingsCache, cacheKey).pipe( Effect.map((loaded) => loaded.settings), ); const reloadSettings = Cache.invalidate(settingsCache, cacheKey).pipe( Effect.andThen(Cache.get(settingsCache, cacheKey)), ); const materializeSettingsSecrets = Effect.fn("serverSettings.materializeSettingsSecrets")( function* (settings: ServerSettings): Effect.fn.Return { const storedSecrets = new Map(); const pending = new Map(); mapSettingsSecrets(settings, (secret) => { if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret); return secret; }); for (const secret of pending.values()) { const value = yield* secretStore.get(secret.secretName).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", providerInstanceId: secret.providerInstanceId, environmentVariable: secret.environmentVariable, cause, }), ), ); storedSecrets.set( secret.secretName, Option.isSome(value) ? textDecoder.decode(value.value) : "", ); } return mapSettingsSecrets(settings, (secret) => ({ value: secret.sensitive && secret.redacted ? (storedSecrets.get(secret.secretName) ?? "") : secret.value, redacted: secret.sensitive && secret.kind === "environment" && secret.redacted, })); }, ); const materializeChanges = (changes: Stream.Stream) => changes.pipe( Stream.mapEffect((settings) => materializeSettingsSecrets(settings).pipe( Effect.catch((error: ServerSettingsError) => Effect.logWarning("failed to materialize settings secrets", { operation: error.operation, providerInstanceId: error.providerInstanceId, environmentVariable: error.environmentVariable, cause: error.cause, }).pipe(Effect.as(settings)), ), ), ), Stream.map(resolveTextGenerationProvider), ); type SecretChange = { readonly secretName: string; readonly providerInstanceId?: string; readonly environmentVariable?: string; } & ( | { readonly kind: "write"; readonly value: Uint8Array } | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" } ); const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) => Effect.sync(() => { const previous = new Map(); mapSettingsSecrets(current, (secret) => { previous.set(secret.secretName, secret); return secret; }); const changes: SecretChange[] = []; const nextSecretNames = new Set(); const settings = mapSettingsSecrets(next, (secret) => { const metadata = { secretName: secret.secretName, ...(secret.providerInstanceId !== undefined ? { providerInstanceId: secret.providerInstanceId } : {}), ...(secret.environmentVariable !== undefined ? { environmentVariable: secret.environmentVariable } : {}), }; if (!secret.sensitive) { changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); return { value: secret.value, redacted: false }; } const old = previous.get(secret.secretName); const inlineValue = old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined; const value = secret.redacted ? inlineValue : secret.value; if (value === undefined) { nextSecretNames.add(secret.secretName); return { value: "", redacted: true }; } if (value.length === 0) { changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); return { value: "", redacted: false }; } nextSecretNames.add(secret.secretName); changes.push({ ...metadata, kind: "write", value: textEncoder.encode(value) }); return { value: "", redacted: true }; }); for (const secret of previous.values()) { if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue; if (changes.some((change) => change.secretName === secret.secretName)) continue; changes.push({ kind: "remove", secretName: secret.secretName, operation: "remove-stale-secret", ...(secret.providerInstanceId !== undefined ? { providerInstanceId: secret.providerInstanceId } : {}), ...(secret.environmentVariable !== undefined ? { environmentVariable: secret.environmentVariable } : {}), }); } return { settings, changes }; }); const rollbackSettingsSecretWrites = ( writes: ReadonlyArray<{ readonly secretName: string; readonly previousValue: Option.Option; readonly providerInstanceId?: string; readonly environmentVariable?: string; }>, ) => Effect.forEach( writes.toReversed(), (write) => (Option.isSome(write.previousValue) ? secretStore.set(write.secretName, write.previousValue.value) : secretStore.remove(write.secretName) ).pipe( Effect.catch((cause) => Effect.logWarning("failed to roll back settings secret", { providerInstanceId: write.providerInstanceId, environmentVariable: write.environmentVariable, cause, }), ), ), { discard: true }, ); const applySettingsSecretChanges = (changes: ReadonlyArray) => { const applied: Array<{ readonly secretName: string; readonly previousValue: Option.Option; readonly providerInstanceId?: string; readonly environmentVariable?: string; }> = []; const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied)); return Effect.forEach( changes, (change) => Effect.gen(function* () { const previousValue = yield* secretStore.get(change.secretName).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", providerInstanceId: change.providerInstanceId, environmentVariable: change.environmentVariable, cause, }), ), ); // A store operation may mutate before reporting an error (for example chmod after rename). applied.push({ ...change, previousValue }); yield* ( change.kind === "write" ? secretStore.set(change.secretName, change.value) : secretStore.remove(change.secretName) ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: change.kind === "write" ? "write-secret" : change.operation, providerInstanceId: change.providerInstanceId, environmentVariable: change.environmentVariable, cause, }), ), ); }), { discard: true }, ).pipe( Effect.tapError(() => rollback), Effect.as(rollback), ); }; const updateAndPersistSettings = ( update: (current: ServerSettings) => Effect.Effect, ): Effect.Effect => writeSemaphore.withPermits(1)( Effect.gen(function* () { const { settings: current, parseFailure } = yield* reloadSettings; if (Option.isSome(parseFailure)) { return yield* new ServerSettingsError({ settingsPath, operation: "read-file", cause: parseFailure.value, }); } const updated = yield* update(current); const persisted = yield* persistSettingsSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); const materialized = yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); if (Exit.isFailure(materializedExit)) { yield* rollbackSecretChanges; return yield* Effect.failCause(materializedExit.cause); } const writeExit = yield* Effect.exit(writeSettingsAtomically(next)); if (Exit.isFailure(writeExit)) { yield* rollbackSecretChanges; return yield* Effect.failCause(writeExit.cause); } return materializedExit.value; }), ); yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() }); yield* emitChange(next); return resolveTextGenerationProvider(materialized); }), ); const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => writeSemaphore.withPermits(1)( getSettingsFromCache.pipe( Effect.flatMap(materializeSettingsSecrets), Effect.map(resolveTextGenerationProvider), Effect.flatMap(use), ), ); const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { const { settings } = yield* reloadSettings; yield* emitChange(settings); }), ); const watchFileChanges = (filePath: string) => { const directory = pathService.dirname(filePath); const fileName = pathService.basename(filePath); const resolvedFilePath = pathService.resolve(filePath); return fs .watch(directory) .pipe( Stream.filter( (event) => event.path === fileName || event.path === filePath || pathService.resolve(directory, event.path) === resolvedFilePath, ), ); }; const startWatcher = Effect.gen(function* () { const settingsDir = pathService.dirname(settingsPath); yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "prepare-directory", cause, }), ), ); const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); // Watch symlink targets' directories for rewrites; re-resolve link changes to follow repointing. const watchLinkTarget = Effect.gen(function* () { const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathService), ); if (linkTargetPath === pathService.resolve(settingsPath)) { return Option.none(); } yield* fs .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true }) .pipe(Effect.ignore({ log: true })); return Option.some(linkTargetPath); }).pipe(Effect.orElseSucceed(() => Option.none())); const initialLinkTarget = yield* watchLinkTarget; const linkTargetEvents = Stream.make(initialLinkTarget).pipe( Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))), Stream.changes, Stream.switchMap( Option.match({ onNone: () => Stream.empty, onSome: (linkTargetPath) => watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })), }), ), ); // Saves emit multiple events; fs.watch may fire before content is flushed. const debouncedSettingsEvents = Stream.merge( watchFileChanges(settingsPath), linkTargetEvents, ).pipe(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* reloadSettings; }); 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.flatMap(materializeSettingsSecrets), Effect.map(resolveTextGenerationProvider), ), updateSettings: (patch) => updateAndPersistSettings((current) => Effect.succeed(applyServerSettingsPatch(current, patch)), ), updateProviderInstance: (mutation, patch = {}) => updateAndPersistSettings((current) => Effect.gen(function* () { yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath); const patched = applyServerSettingsPatch(current, patch); return applyProviderInstanceMutation(patched, mutation); }), ), withSettingsSnapshot, get streamChanges() { return materializeChanges(Stream.fromPubSub(changesPubSub)); }, get subscribeChanges() { return PubSub.subscribe(changesPubSub).pipe( Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))), ); }, } satisfies ServerSettingsService["Service"]; }); export const layer = Layer.effect(ServerSettingsService, make);