authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-25 01:10:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-25 01:10:55-07:00
log06de9e90ab0832727968c58ce72282b94a95ae17
treedb45b2074de7e33495a8a56ecc9efbe9d86099bf
parent589a9d0e29e26b290024899c16f0a4e0d6aebe26
signature Signed by PGP key B5690EEEBB952194

fix(server): keep previously used providers working after upgrades (#8176)


4 files changed, 426 insertions(+), 16 deletions(-)

apps/server/src/server.ts+4-1
...@@ -143,7 +143,10 @@ const PtyAdapterLive = Layer.unwrap(...@@ -143,7 +143,10 @@ const PtyAdapterLive = Layer.unwrap(
143 }),143 }),
144);144);
145145
146const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer));146const ServerSettingsLayerLive = ServerSettings.layer.pipe(
147 Layer.provide(ServerSecretStore.layer),
148 Layer.provideMerge(SqlitePersistenceLayerLive),
149);
147150
148const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe(151const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe(
149 Layer.provide(ResourceMonitorBinary.layer),152 Layer.provide(ResourceMonitorBinary.layer),
apps/server/src/serverSettings.test.ts+296-1
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 ProviderDriverKind,4 ProviderDriverKind,
5 ProviderInstanceId,5 ProviderInstanceId,
6 resolveProviderInstanceEnabled,
6 ServerSettings,7 ServerSettings,
7 ServerSettingsPatch,8 ServerSettingsPatch,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
...@@ -16,8 +17,10 @@ import * as Option from "effect/Option";...@@ -16,8 +17,10 @@ import * as Option from "effect/Option";
16import * as PlatformError from "effect/PlatformError";17import * as PlatformError from "effect/PlatformError";
17import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";19import * as Stream from "effect/Stream";
20import * as SqlClient from "effect/unstable/sql/SqlClient";
19import * as ServerSecretStore from "./auth/ServerSecretStore.ts";21import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
20import * as ServerConfig from "./config.ts";22import * as ServerConfig from "./config.ts";
23import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
21import * as ServerSettingsModule from "./serverSettings.ts";24import * as ServerSettingsModule from "./serverSettings.ts";
2225
23const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);26const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
...@@ -26,6 +29,7 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);...@@ -26,6 +29,7 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
26const makeServerSettingsLayer = () =>29const makeServerSettingsLayer = () =>
27 ServerSettingsModule.layer.pipe(30 ServerSettingsModule.layer.pipe(
28 Layer.provide(ServerSecretStore.layer),31 Layer.provide(ServerSecretStore.layer),
32 Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)),
29 Layer.provideMerge(33 Layer.provideMerge(
30 Layer.fresh(34 Layer.fresh(
31 ServerConfig.layerTest(process.cwd(), {35 ServerConfig.layerTest(process.cwd(), {
...@@ -47,6 +51,27 @@ const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError)...@@ -47,6 +51,27 @@ const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError)
47 }),51 }),
48 );52 );
4953
54const recordProviderUsage = (provider: string, instanceId: string | null = provider) =>
55 Effect.gen(function* () {
56 const sql = yield* SqlClient.SqlClient;
57 yield* sql`
58 INSERT INTO projection_thread_sessions (
59 thread_id,
60 status,
61 provider_name,
62 provider_instance_id,
63 updated_at
64 )
65 VALUES (
66 ${`thread-${instanceId ?? provider}`},
67 ${"ready"},
68 ${provider},
69 ${instanceId},
70 ${"2026-08-25T00:00:00.000Z"}
71 )
72 `;
73 });
74
50it.layer(NodeServices.layer)("server settings", (it) => {75it.layer(NodeServices.layer)("server settings", (it) => {
51 it.effect("preserves context when reading a provider environment secret fails", () => {76 it.effect("preserves context when reading a provider environment secret fails", () => {
52 const platformCause = PlatformError.systemError({77 const platformCause = PlatformError.systemError({
...@@ -67,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -67,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
67 );92 );
68 const settingsLayer = ServerSettingsModule.layer.pipe(93 const settingsLayer = ServerSettingsModule.layer.pipe(
69 Layer.provide(makeFailingSecretStoreLayer(cause)),94 Layer.provide(makeFailingSecretStoreLayer(cause)),
95 Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)),
70 Layer.provideMerge(configLayer),96 Layer.provideMerge(configLayer),
71 );97 );
7298
...@@ -92,6 +118,23 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -92,6 +118,23 @@ it.layer(NodeServices.layer)("server settings", (it) => {
92 }).pipe(Effect.provide(settingsLayer));118 }).pipe(Effect.provide(settingsLayer));
93 });119 });
94120
121 it.effect("identifies provider history query failures", () =>
122 Effect.gen(function* () {
123 const serverConfig = yield* ServerConfig.ServerConfig;
124 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
125 const sql = yield* SqlClient.SqlClient;
126 yield* sql`DROP TABLE projection_thread_sessions`;
127
128 const error = yield* Effect.flip(serverSettings.getSettings);
129
130 assert.deepInclude(error, {
131 _tag: "ServerSettingsError",
132 operation: "read-provider-history",
133 settingsPath: serverConfig.settingsPath,
134 });
135 }).pipe(Effect.provide(makeServerSettingsLayer())),
136 );
137
95 it.effect("decodes nested settings patches", () =>138 it.effect("decodes nested settings patches", () =>
96 Effect.gen(function* () {139 Effect.gen(function* () {
97 assert.deepEqual(140 assert.deepEqual(
...@@ -488,6 +531,251 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -488,6 +531,251 @@ it.layer(NodeServices.layer)("server settings", (it) => {
488 }).pipe(Effect.provide(makeServerSettingsLayer())),531 }).pipe(Effect.provide(makeServerSettingsLayer())),
489 );532 );
490533
534 it.effect("enables previously used providers from sparse settings files", () =>
535 Effect.gen(function* () {
536 const serverConfig = yield* ServerConfig.ServerConfig;
537 const fileSystem = yield* FileSystem.FileSystem;
538 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
539 yield* fileSystem.writeFileString(
540 serverConfig.settingsPath,
541 '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}',
542 );
543 yield* recordProviderUsage("opencode");
544
545 const settings = yield* serverSettings.getSettings;
546
547 assert.isFalse(settings.providers.grok.enabled);
548 assert.isTrue(settings.providers.opencode.enabled);
549 assert.isFalse(settings.providers.cursor.enabled);
550 assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096");
551 }).pipe(Effect.provide(makeServerSettingsLayer())),
552 );
553
554 it.effect("preserves existing provider instances without explicit enabled flags", () =>
555 Effect.gen(function* () {
556 const serverConfig = yield* ServerConfig.ServerConfig;
557 const fileSystem = yield* FileSystem.FileSystem;
558 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
559 yield* fileSystem.writeFileString(
560 serverConfig.settingsPath,
561 '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}',
562 );
563 yield* recordProviderUsage("cursor", "cursor_work");
564 yield* recordProviderUsage("grok", null);
565 yield* recordProviderUsage("opencode", "opencode_work");
566
567 const settings = yield* serverSettings.getSettings;
568
569 assert.isTrue(settings.providers.cursor.enabled);
570 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled);
571 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled);
572 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled);
573 const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")];
574 assert.isDefined(unused);
575 assert.isFalse(resolveProviderInstanceEnabled(unused));
576 }).pipe(Effect.provide(makeServerSettingsLayer())),
577 );
578
579 it.effect("preserves explicit provider disables in existing settings files", () =>
580 Effect.gen(function* () {
581 const serverConfig = yield* ServerConfig.ServerConfig;
582 const fileSystem = yield* FileSystem.FileSystem;
583 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
584 yield* fileSystem.writeFileString(
585 serverConfig.settingsPath,
586 '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}',
587 );
588 yield* recordProviderUsage("grok");
589 yield* recordProviderUsage("opencode");
590 yield* recordProviderUsage("cursor");
591
592 const settings = yield* serverSettings.getSettings;
593
594 assert.isFalse(settings.providers.grok.enabled);
595 assert.isFalse(settings.providers.opencode.enabled);
596 assert.isFalse(settings.providers.cursor.enabled);
597 assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled);
598 assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled);
599 assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled);
600 }).pipe(Effect.provide(makeServerSettingsLayer())),
601 );
602
603 it.effect("keeps unused providers disabled in existing sparse settings files", () =>
604 Effect.gen(function* () {
605 const serverConfig = yield* ServerConfig.ServerConfig;
606 const fileSystem = yield* FileSystem.FileSystem;
607 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
608 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");
609
610 const settings = yield* serverSettings.getSettings;
611
612 assert.isFalse(settings.providers.grok.enabled);
613 assert.isFalse(settings.providers.opencode.enabled);
614 assert.isFalse(settings.providers.cursor.enabled);
615 }).pipe(Effect.provide(makeServerSettingsLayer())),
616 );
617
618 it.effect("preserves provider history when no settings file exists", () =>
619 Effect.gen(function* () {
620 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
621 yield* recordProviderUsage("grok");
622
623 const settings = yield* serverSettings.getSettings;
624
625 assert.isTrue(settings.providers.grok.enabled);
626 assert.isFalse(settings.providers.opencode.enabled);
627 assert.isFalse(settings.providers.cursor.enabled);
628 }).pipe(Effect.provide(makeServerSettingsLayer())),
629 );
630
631 it.effect("preserves provider history when the settings file is invalid", () =>
632 Effect.gen(function* () {
633 const serverConfig = yield* ServerConfig.ServerConfig;
634 const fileSystem = yield* FileSystem.FileSystem;
635 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
636 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json");
637 yield* recordProviderUsage("cursor");
638
639 const settings = yield* serverSettings.getSettings;
640
641 assert.isTrue(settings.providers.cursor.enabled);
642 assert.isFalse(settings.providers.grok.enabled);
643 assert.isFalse(settings.providers.opencode.enabled);
644 }).pipe(Effect.provide(makeServerSettingsLayer())),
645 );
646
647 it.effect("preserves valid provider flags when another settings field is invalid", () =>
648 Effect.gen(function* () {
649 const serverConfig = yield* ServerConfig.ServerConfig;
650 const fileSystem = yield* FileSystem.FileSystem;
651 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
652 yield* fileSystem.writeFileString(
653 serverConfig.settingsPath,
654 '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}',
655 );
656 yield* recordProviderUsage("cursor");
657
658 const settings = yield* serverSettings.getSettings;
659
660 assert.isFalse(settings.providers.cursor.enabled);
661 assert.isTrue(settings.providers.grok.enabled);
662 assert.isFalse(settings.providers.opencode.enabled);
663 }).pipe(Effect.provide(makeServerSettingsLayer())),
664 );
665
666 it.effect("restores providers from persisted runtime sessions", () =>
667 Effect.gen(function* () {
668 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
669 const sql = yield* SqlClient.SqlClient;
670 yield* sql`
671 INSERT INTO provider_session_runtime (
672 thread_id,
673 provider_name,
674 provider_instance_id,
675 adapter_key,
676 status,
677 last_seen_at
678 )
679 VALUES (
680 ${"thread-opencode-runtime"},
681 ${"opencode"},
682 ${"opencode"},
683 ${"opencode"},
684 ${"ready"},
685 ${"2026-08-25T00:00:00.000Z"}
686 )
687 `;
688
689 const settings = yield* serverSettings.getSettings;
690
691 assert.isFalse(settings.providers.grok.enabled);
692 assert.isTrue(settings.providers.opencode.enabled);
693 assert.isFalse(settings.providers.cursor.enabled);
694 }).pipe(Effect.provide(makeServerSettingsLayer())),
695 );
696
697 it.effect("persists explicit disables after a provider has been used", () =>
698 Effect.gen(function* () {
699 const serverConfig = yield* ServerConfig.ServerConfig;
700 const fileSystem = yield* FileSystem.FileSystem;
701 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
702 yield* recordProviderUsage("grok");
703
704 assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled);
705
706 const settings = yield* serverSettings.updateSettings({
707 providers: { grok: { enabled: false } },
708 });
709 assert.isFalse(settings.providers.grok.enabled);
710
711 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
712 // @effect-diagnostics-next-line preferSchemaOverJson:off
713 assert.isFalse(JSON.parse(raw).providers.grok.enabled);
714 }).pipe(Effect.provide(makeServerSettingsLayer())),
715 );
716
717 it.effect("persists explicit provider enables before their first use", () =>
718 Effect.gen(function* () {
719 const serverConfig = yield* ServerConfig.ServerConfig;
720 const fileSystem = yield* FileSystem.FileSystem;
721 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
722
723 yield* serverSettings.updateSettings({
724 providers: {
725 cursor: { enabled: true },
726 grok: { enabled: true },
727 opencode: { enabled: true },
728 },
729 });
730 yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" });
731
732 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
733 // @effect-diagnostics-next-line preferSchemaOverJson:off
734 const persisted = JSON.parse(raw);
735 assert.isTrue(persisted.providers.cursor.enabled);
736 assert.isTrue(persisted.providers.grok.enabled);
737 assert.isTrue(persisted.providers.opencode.enabled);
738 }).pipe(Effect.provide(makeServerSettingsLayer())),
739 );
740
741 it.effect("keeps optional providers disabled after a new installation writes settings", () =>
742 Effect.gen(function* () {
743 const serverConfig = yield* ServerConfig.ServerConfig;
744 const fileSystem = yield* FileSystem.FileSystem;
745 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
746
747 const initial = yield* serverSettings.getSettings;
748 assert.isFalse(initial.providers.grok.enabled);
749 assert.isFalse(initial.providers.opencode.enabled);
750 assert.isFalse(initial.providers.cursor.enabled);
751
752 const next = yield* serverSettings.updateSettings({
753 addProjectBaseDirectory: "~/Development",
754 providerInstances: {
755 [ProviderInstanceId.make("grok")]: {
756 driver: ProviderDriverKind.make("grok"),
757 config: {},
758 },
759 },
760 });
761
762 assert.isFalse(next.providers.grok.enabled);
763 assert.isFalse(next.providers.opencode.enabled);
764 assert.isFalse(next.providers.cursor.enabled);
765 const grok = next.providerInstances[ProviderInstanceId.make("grok")];
766 assert.isDefined(grok);
767 assert.isFalse(resolveProviderInstanceEnabled(grok));
768
769 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
770 // @effect-diagnostics-next-line preferSchemaOverJson:off
771 const persisted = JSON.parse(raw);
772 assert.isFalse(persisted.providers.cursor.enabled);
773 assert.isFalse(persisted.providers.grok.enabled);
774 assert.isFalse(persisted.providers.opencode.enabled);
775 assert.isUndefined(persisted.providerInstances.grok.enabled);
776 }).pipe(Effect.provide(makeServerSettingsLayer())),
777 );
778
491 it.effect("folds a legacy in-config enabled flag into the envelope on load", () =>779 it.effect("folds a legacy in-config enabled flag into the envelope on load", () =>
492 Effect.gen(function* () {780 Effect.gen(function* () {
493 const serverConfig = yield* ServerConfig.ServerConfig;781 const serverConfig = yield* ServerConfig.ServerConfig;
...@@ -635,7 +923,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -635,7 +923,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
635 }).pipe(Effect.provide(makeServerSettingsLayer())),923 }).pipe(Effect.provide(makeServerSettingsLayer())),
636 );924 );
637925
638 it.effect("writes only non-default server settings to disk", () =>926 it.effect("writes non-default settings and explicit optional provider defaults to disk", () =>
639 Effect.gen(function* () {927 Effect.gen(function* () {
640 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;928 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
641 const serverConfig = yield* ServerConfig.ServerConfig;929 const serverConfig = yield* ServerConfig.ServerConfig;
...@@ -672,7 +960,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -672,7 +960,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
672 codex: {960 codex: {
673 binaryPath: "/opt/homebrew/bin/codex",961 binaryPath: "/opt/homebrew/bin/codex",
674 },962 },
963 cursor: {
964 enabled: false,
965 },
966 grok: {
967 enabled: false,
968 },
675 opencode: {969 opencode: {
970 enabled: false,
676 serverUrl: "http://127.0.0.1:4096",971 serverUrl: "http://127.0.0.1:4096",
677 serverPassword: "secret-password",972 serverPassword: "secret-password",
678 },973 },
apps/server/src/serverSettings.ts+125-14
...@@ -42,6 +42,7 @@ import * as Schema from "effect/Schema";...@@ -42,6 +42,7 @@ import * as Schema from "effect/Schema";
42import * as Semaphore from "effect/Semaphore";42import * as Semaphore from "effect/Semaphore";
43import * as Scope from "effect/Scope";43import * as Scope from "effect/Scope";
44import * as Stream from "effect/Stream";44import * as Stream from "effect/Stream";
45import * as SqlClient from "effect/unstable/sql/SqlClient";
45import { writeFileStringAtomically } from "./atomicWrite.ts";46import { writeFileStringAtomically } from "./atomicWrite.ts";
46import * as ServerConfig from "./config.ts";47import * as ServerConfig from "./config.ts";
47import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";48import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
...@@ -230,6 +231,66 @@ export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>...@@ -230,6 +231,66 @@ export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>
230231
231const ServerSettingsJson = fromLenientJson(ServerSettings);232const ServerSettingsJson = fromLenientJson(ServerSettings);
232const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);233const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);
234const PersistedOptionalProviderSettings = Schema.Struct({
235 providers: Schema.optionalKey(
236 Schema.Struct({
237 cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
238 grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
239 opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
240 }),
241 ),
242});
243const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit(
244 fromLenientJson(PersistedOptionalProviderSettings),
245);
246
247function restoreUsedProviders(
248 settings: ServerSettings,
249 persisted: typeof PersistedOptionalProviderSettings.Type,
250 providerHistory: ReadonlyArray<{
251 readonly providerName: string;
252 readonly providerInstanceId: string | null;
253 }>,
254): ServerSettings {
255 const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName));
256 const usedProviderInstances = new Set(
257 providerHistory.map(
258 ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName,
259 ),
260 );
261 const providerInstances = Object.fromEntries(
262 Object.entries(settings.providerInstances).map(([instanceId, instance]) => [
263 instanceId,
264 instance.enabled === undefined &&
265 (instance.driver === "cursor" ||
266 instance.driver === "grok" ||
267 instance.driver === "opencode") &&
268 usedProviderInstances.has(instanceId)
269 ? { ...instance, enabled: true }
270 : instance,
271 ]),
272 );
273
274 return {
275 ...settings,
276 providers: {
277 ...settings.providers,
278 cursor: {
279 ...settings.providers.cursor,
280 enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"),
281 },
282 grok: {
283 ...settings.providers.grok,
284 enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"),
285 },
286 opencode: {
287 ...settings.providers.opencode,
288 enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"),
289 },
290 },
291 providerInstances,
292 };
293}
233294
234function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {295function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {
235 return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection)296 return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection)
...@@ -265,6 +326,17 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([...@@ -265,6 +326,17 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
265 "textGenerationModelSelection",326 "textGenerationModelSelection",
266]);327]);
267328
329// Preserve both enabled states because provider history cannot recover a new opt-in.
330const PERSISTED_SERVER_SETTINGS_DEFAULTS = {
331 ...DEFAULT_SERVER_SETTINGS,
332 providers: {
333 ...DEFAULT_SERVER_SETTINGS.providers,
334 cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined },
335 grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined },
336 opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined },
337 },
338};
339
268function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {340function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
269 if (Array.isArray(current) || Array.isArray(defaults)) {341 if (Array.isArray(current) || Array.isArray(defaults)) {
270 return Equal.equals(current, defaults) ? undefined : current;342 return Equal.equals(current, defaults) ? undefined : current;
...@@ -304,6 +376,7 @@ const make = Effect.gen(function* () {...@@ -304,6 +376,7 @@ const make = Effect.gen(function* () {
304 const fs = yield* FileSystem.FileSystem;376 const fs = yield* FileSystem.FileSystem;
305 const pathService = yield* Path.Path;377 const pathService = yield* Path.Path;
306 const secretStore = yield* ServerSecretStore.ServerSecretStore;378 const secretStore = yield* ServerSecretStore.ServerSecretStore;
379 const sql = yield* SqlClient.SqlClient;
307 const writeSemaphore = yield* Semaphore.make(1);380 const writeSemaphore = yield* Semaphore.make(1);
308 const cacheKey = "settings" as const;381 const cacheKey = "settings" as const;
309 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();382 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
...@@ -338,21 +411,59 @@ const make = Effect.gen(function* () {...@@ -338,21 +411,59 @@ const make = Effect.gen(function* () {
338 );411 );
339412
340 const loadSettingsFromDisk = Effect.gen(function* () {413 const loadSettingsFromDisk = Effect.gen(function* () {
341 if (!(yield* readConfigExists)) {414 let settings = DEFAULT_SERVER_SETTINGS;
342 return DEFAULT_SERVER_SETTINGS;415 let persisted: typeof PersistedOptionalProviderSettings.Type = {};
416
417 if (yield* readConfigExists) {
418 const raw = yield* readRawConfig;
419 const decoded = decodeServerSettingsJsonExit(raw);
420 const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw);
421 if (persistedSettings._tag === "Success") {
422 persisted = persistedSettings.value;
423 }
424 if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") {
425 const failure = decoded._tag === "Failure" ? decoded : persistedSettings;
426 if (failure._tag === "Failure") {
427 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
428 path: settingsPath,
429 issues: Cause.pretty(failure.cause),
430 cause: failure.cause,
431 });
432 }
433 } else {
434 settings = decoded.value;
435 }
343 }436 }
344437
345 const raw = yield* readRawConfig;438 const providerHistory = yield* sql<{
346 const decoded = decodeServerSettingsJsonExit(raw);439 readonly providerName: string;
347 if (decoded._tag === "Failure") {440 readonly providerInstanceId: string | null;
348 yield* Effect.logWarning("failed to parse settings.json, using defaults", {441 }>`
349 path: settingsPath,442 SELECT DISTINCT
350 issues: Cause.pretty(decoded.cause),443 provider_name AS "providerName",
351 cause: decoded.cause,444 provider_instance_id AS "providerInstanceId"
352 });445 FROM projection_thread_sessions
353 return DEFAULT_SERVER_SETTINGS;446 WHERE provider_name IN ('cursor', 'grok', 'opencode')
354 }447 UNION
355 return foldProviderInstanceEnabledFlags(decoded.value);448 SELECT DISTINCT
449 provider_name AS "providerName",
450 provider_instance_id AS "providerInstanceId"
451 FROM provider_session_runtime
452 WHERE provider_name IN ('cursor', 'grok', 'opencode')
453 `.pipe(
454 Effect.mapError(
455 (cause) =>
456 new ServerSettingsError({
457 settingsPath,
458 operation: "read-provider-history",
459 cause,
460 }),
461 ),
462 );
463
464 return foldProviderInstanceEnabledFlags(
465 restoreUsedProviders(settings, persisted, providerHistory),
466 );
356 });467 });
357468
358 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({469 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
...@@ -528,7 +639,7 @@ const make = Effect.gen(function* () {...@@ -528,7 +639,7 @@ const make = Effect.gen(function* () {
528 const writeSettingsAtomically = Effect.fnUntraced(639 const writeSettingsAtomically = Effect.fnUntraced(
529 function* (settings: ServerSettings) {640 function* (settings: ServerSettings) {
530 const sparseSettingsJson = yield* encodeServerSettingsJson(641 const sparseSettingsJson = yield* encodeServerSettingsJson(
531 stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},642 stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {},
532 );643 );
533644
534 return yield* writeFileStringAtomically({645 return yield* writeFileStringAtomically({
packages/contracts/src/settings.ts+1
...@@ -755,6 +755,7 @@ export const ServerSettingsOperation = Schema.Literals([...@@ -755,6 +755,7 @@ export const ServerSettingsOperation = Schema.Literals([
755 "normalize",755 "normalize",
756 "check-exists",756 "check-exists",
757 "read-file",757 "read-file",
758 "read-provider-history",
758 "read-secret",759 "read-secret",
759 "remove-secret",760 "remove-secret",
760 "remove-stale-secret",761 "remove-stale-secret",