1/**
2 * ServerSettings - Server-authoritative settings service.
3 *
4 * Owns persistence, validation, and change notification of settings that affect
5 * server-side behavior (binary paths, streaming mode, env mode, custom models,
6 * text generation model selection).
7 *
8 * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub +
9 * Semaphore + FileSystem.watch for concurrency and external edit detection.
10 *
11 * @module ServerSettings
12 */
13import {
14 DEFAULT_SERVER_SETTINGS,
15 ModelSelection,
16 ProjectId,
17 ProjectScript,
18 ProjectSettingsOverrides,
19 type ProviderInstanceConfig,
20 type ProviderInstanceMutation,
21 defaultInstanceIdForDriver,
22 isProviderDriverKind,
23 ProviderDriverKind,
24 ProviderInstanceId,
25 resolveProviderInstanceEnabled,
26 ResponseStreamingMode,
27 ServerSettings,
28 ServerSettingsError,
29 type ServerSettingsPatch,
30} from "@t3tools/contracts";
31import * as Cache from "effect/Cache";
32import * as Cause from "effect/Cause";
33import * as Context from "effect/Context";
34import * as Deferred from "effect/Deferred";
35import * as Duration from "effect/Duration";
36import * as Equal from "effect/Equal";
37import * as Effect from "effect/Effect";
38import * as Exit from "effect/Exit";
39import * as FileSystem from "effect/FileSystem";
40import * as Layer from "effect/Layer";
41import * as Option from "effect/Option";
42import * as Path from "effect/Path";
43import * as PubSub from "effect/PubSub";
44import * as Ref from "effect/Ref";
45import * as Schema from "effect/Schema";
46import * as SchemaTransformation from "effect/SchemaTransformation";
47import * as Semaphore from "effect/Semaphore";
48import * as Scope from "effect/Scope";
49import * as Stream from "effect/Stream";
50import * as SqlClient from "effect/sql/SqlClient";
51import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
52import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
53import * as ServerConfig from "./config.ts";
54import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
55import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
56import {
57 applyServerSettingsPatch,
58 deriveLegacyProjectOverrides,
59 fallbackTextGenerationModelSelection,
60 isModelSelectionProviderEnabled,
61} from "@t3tools/shared/serverSettings";
62import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
63import {
64 readSecretFieldValues,
65 sourceControlHostSecretName,
66} from "./sourceControl/sourceControlHostSecrets.ts";
67
68import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts";
69
70export {
71 resolveReviewGuideModelSelection,
72 resolveSourceControlWriterModelSelection,
73} from "@t3tools/shared/serverSettings";
74
75const encodeServerSettings = Schema.encodeEffect(ServerSettings);
76const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
77const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
78
79const textEncoder = new TextEncoder();
80const textDecoder = new TextDecoder();
81
82/**
83 * Fold the legacy in-config `enabled` flag into the envelope-level
84 * `ProviderInstanceConfig.enabled` and strip it from the config blob, so
85 * explicit provider instances carry exactly one enabled flag. Old settings
86 * files can hold both flags with conflicting values; an explicit false on
87 * either side wins so a user's disable is never silently undone. Runs on
88 * every load and update — the file converges on the next write.
89 */
90const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => {
91 let changed = false;
92 const providerInstances: Record<string, ProviderInstanceConfig> = {};
93 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
94 const config = instance.config;
95 // Only fold boolean flags: a malformed `enabled` (e.g. `"false"`) must
96 // stay in the blob so driver schema validation flags it instead of the
97 // fold silently repairing the config.
98 if (
99 config === null ||
100 typeof config !== "object" ||
101 Array.isArray(config) ||
102 typeof (config as { readonly enabled?: unknown }).enabled !== "boolean"
103 ) {
104 providerInstances[instanceId] = instance;
105 continue;
106 }
107 const { enabled: configEnabled, ...restConfig } = config as Record<string, unknown> & {
108 readonly enabled: boolean;
109 };
110 const resolved =
111 instance.enabled === false || configEnabled === false
112 ? false
113 : (instance.enabled ?? configEnabled);
114 changed = true;
115 providerInstances[instanceId] = {
116 ...instance,
117 enabled: resolved,
118 config: restConfig,
119 } satisfies ProviderInstanceConfig;
120 }
121 if (!changed) {
122 return settings;
123 }
124 return {
125 ...settings,
126 providerInstances: providerInstances as ServerSettings["providerInstances"],
127 };
128};
129
130const normalizeServerSettings = (
131 settings: ServerSettings,
132): Effect.Effect<ServerSettings, ServerSettingsError> =>
133 encodeServerSettings(settings).pipe(
134 Effect.flatMap(decodeServerSettings),
135 Effect.map(foldProviderInstanceEnabledFlags),
136 Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })),
137 Effect.mapError(
138 (cause) =>
139 new ServerSettingsError({
140 settingsPath: "<memory>",
141 operation: "normalize",
142 cause,
143 }),
144 ),
145 );
146
147export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings {
148 return mapSettingsSecrets(settings, (secret) =>
149 secret.sensitive
150 ? { value: "", redacted: secret.redacted || secret.value.length > 0 }
151 : { value: secret.value, redacted: false },
152 );
153}
154
155export function applyProviderInstanceMutation(
156 settings: ServerSettings,
157 mutation: ProviderInstanceMutation,
158): ServerSettings {
159 const providerInstances = { ...settings.providerInstances };
160 if (mutation.operation === "upsert" || mutation.operation === "create") {
161 providerInstances[mutation.instanceId] = mutation.instance;
162 } else {
163 delete providerInstances[mutation.instanceId];
164 }
165 return { ...settings, providerInstances };
166}
167
168function ensureProviderInstanceMutationAllowed(
169 settings: ServerSettings,
170 mutation: ProviderInstanceMutation,
171 settingsPath: string,
172): Effect.Effect<void, ServerSettingsError> {
173 if (
174 mutation.operation === "create" &&
175 settings.providerInstances[mutation.instanceId] !== undefined
176 ) {
177 return Effect.fail(
178 new ServerSettingsError({
179 settingsPath,
180 operation: "create-provider-instance",
181 providerInstanceId: mutation.instanceId,
182 }),
183 );
184 }
185 return Effect.void;
186}
187
188export class ServerSettingsService extends Context.Service<
189 ServerSettingsService,
190 {
191 /** Start the settings runtime and attach file watching. */
192 readonly start: Effect.Effect<void, ServerSettingsError>;
193
194 /** Await settings runtime readiness. */
195 readonly ready: Effect.Effect<void, ServerSettingsError>;
196
197 /** Read the current settings. */
198 readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>;
199
200 /** Patch settings and persist. Returns the new full settings object. */
201 readonly updateSettings: (
202 patch: ServerSettingsPatch,
203 ) => Effect.Effect<ServerSettings, ServerSettingsError>;
204
205 /** Apply a patch and one provider-instance mutation against the same latest settings snapshot. */
206 readonly updateProviderInstance: (
207 mutation: ProviderInstanceMutation,
208 patch?: ServerSettingsPatch,
209 ) => Effect.Effect<ServerSettings, ServerSettingsError>;
210
211 /** Run an effect against a settings snapshot while settings writes are paused. */
212 readonly withSettingsSnapshot: <A, E, R>(
213 use: (settings: ServerSettings) => Effect.Effect<A, E, R>,
214 ) => Effect.Effect<A, E | ServerSettingsError, R>;
215
216 /** Stream of settings change events. */
217 readonly streamChanges: Stream.Stream<ServerSettings>;
218
219 /**
220 * Acquire a settings change subscription synchronously in the current
221 * fiber. Use this before reading a snapshot when changes between the
222 * snapshot and a lazily started stream must not be lost.
223 */
224 readonly subscribeChanges: Effect.Effect<Stream.Stream<ServerSettings>, never, Scope.Scope>;
225 }
226>()("t3/serverSettings/ServerSettingsService") {
227 /** @deprecated Import and use `layerTest` from this module. */
228 static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) => layerTest(overrides);
229}
230
231const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>
232 Effect.gen(function* () {
233 const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } =
234 overrides;
235 const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
236 const initialSettings = yield* normalizeServerSettings({
237 ...merged,
238 ...(automaticGitFetchInterval !== undefined
239 ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }
240 : {}),
241 ...(providerHealthRefreshInterval !== undefined
242 ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration }
243 : {}),
244 });
245 const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);
246 const writeSemaphore = yield* Semaphore.make(1);
247 const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider));
248
249 const updateTestSettings = (
250 update: (current: ServerSettings) => Effect.Effect<ServerSettings, ServerSettingsError>,
251 ): Effect.Effect<ServerSettings, ServerSettingsError> =>
252 writeSemaphore.withPermits(1)(
253 Ref.get(currentSettingsRef).pipe(
254 Effect.flatMap(update),
255 Effect.flatMap(normalizeServerSettings),
256 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
257 Effect.map(resolveTextGenerationProvider),
258 ),
259 );
260
261 return {
262 start: Effect.void,
263 ready: Effect.void,
264 getSettings,
265 updateSettings: (patch) =>
266 updateTestSettings((currentSettings) =>
267 Effect.succeed(applyServerSettingsPatch(currentSettings, patch)),
268 ),
269 updateProviderInstance: (mutation, patch = {}) =>
270 updateTestSettings((currentSettings) =>
271 Effect.gen(function* () {
272 yield* ensureProviderInstanceMutationAllowed(
273 currentSettings,
274 mutation,
275 "test settings",
276 );
277 const patched = applyServerSettingsPatch(currentSettings, patch);
278 return applyProviderInstanceMutation(patched, mutation);
279 }),
280 ),
281 withSettingsSnapshot: (use) =>
282 writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))),
283 streamChanges: Stream.empty,
284 subscribeChanges: Effect.succeed(Stream.empty),
285 } satisfies ServerSettingsService["Service"];
286 });
287
288export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>
289 Layer.effect(ServerSettingsService, makeTest(overrides));
290
291// Migrate saved token delivery without accepting it in settings writes or
292// letting one retired value reset the rest of the environment's settings.
293const PersistedResponseStreamingMode = Schema.Union([
294 ResponseStreamingMode,
295 Schema.Literal("token"),
296]).pipe(
297 Schema.decodeTo(
298 ResponseStreamingMode,
299 SchemaTransformation.transform({
300 decode: (mode) => (mode === "token" ? "paragraph" : mode),
301 encode: (mode) => mode,
302 }),
303 ),
304);
305const ServerSettingsJson = fromLenientJson(
306 Schema.Struct({
307 ...ServerSettings.fields,
308 responseStreamingMode: PersistedResponseStreamingMode.pipe(
309 Schema.withDecodingDefault(Effect.succeed("paragraph" as const)),
310 ),
311 projectSettingsOverrides: Schema.Record(
312 ProjectId,
313 Schema.Struct({
314 ...ProjectSettingsOverrides.fields,
315 responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode),
316 }),
317 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
318 }),
319);
320const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);
321/**
322 * The retired `providers.<kind>` map, read without its old schemas. Before
323 * `providerInstances` existed each built-in driver had one blob there; it
324 * now only feeds `migrateLegacyProviderSettings`.
325 */
326const LegacyProviderSettingsJson = fromLenientJson(
327 Schema.Struct({
328 providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
329 // Retired for `sourceControlHosts`; read only by `migrateLegacySourceControlSettings`.
330 bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
331 github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
332 }),
333);
334const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson);
335
336/** The secret store names the retired `bitbucket` and `github` keys kept their tokens under. */
337const LEGACY_BITBUCKET_SECRET_NAMES: Readonly<Record<string, string>> = {
338 accessToken: "bitbucket-access-token",
339 apiToken: "bitbucket-api-token",
340};
341const legacyGitHubTokenSecretName = (host: string) =>
342 `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`;
343
344/**
345 * Folds the retired `bitbucket` and `github` keys into `sourceControlHosts`, unless the new key
346 * already holds that host. Returns the secret store entries to copy to their new names.
347 */
348function migrateLegacySourceControlSettings(
349 settings: ServerSettings,
350 legacy: {
351 readonly bitbucket?: Readonly<Record<string, unknown>> | undefined;
352 readonly github?: Readonly<Record<string, unknown>> | undefined;
353 },
354): {
355 readonly settings: ServerSettings;
356 readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>;
357} {
358 const sourceControlHosts = { ...settings.sourceControlHosts };
359 const secretMoves: Array<{ readonly from: string; readonly to: string }> = [];
360 if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) {
361 sourceControlHosts.bitbucket = legacy.bitbucket;
362 for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) {
363 if (legacy.bitbucket[field] !== SECRET_REDACTED) continue;
364 secretMoves.push({
365 from,
366 to: sourceControlHostSecretName({ kind: "bitbucket", field, serverHost: null }),
367 });
368 }
369 }
370 if (legacy.github !== undefined && sourceControlHosts.github === undefined) {
371 sourceControlHosts.github = legacy.github;
372 for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) {
373 if (serverHost === null || value !== SECRET_REDACTED) continue;
374 secretMoves.push({
375 from: legacyGitHubTokenSecretName(serverHost),
376 to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }),
377 });
378 }
379 }
380 return { settings: { ...settings, sourceControlHosts }, secretMoves };
381}
382
383// Drivers that start disabled, so a session in the history means the user
384// turned them on before the instance kept an explicit flag.
385const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set(
386 ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)),
387);
388
389/**
390 * Move each customized legacy `providers.<kind>` blob into the driver's
391 * default `providerInstances` slot. An explicit instance already in that slot
392 * wins. Blobs that match a fresh install (empty, or only an `enabled` flag
393 * equal to the driver default) carry nothing and are dropped.
394 *
395 * A cursor/grok/opencode slot without an `enabled` flag is enabled when
396 * provider history shows the driver was used, which is how those drivers
397 * were opted into before they had an explicit flag. Callers run this only
398 * while the settings file still has the retired map, so it happens once.
399 */
400function migrateLegacyProviderSettings(
401 settings: ServerSettings,
402 legacyProviders: Readonly<Record<string, unknown>>,
403 providerHistory: ReadonlyArray<{
404 readonly providerName: string;
405 readonly providerInstanceId: string | null;
406 }>,
407): ServerSettings {
408 // History rows are raw SQL text; compare them as strings.
409 const usedProviders = new Set<string>(providerHistory.map(({ providerName }) => providerName));
410 const usedProviderInstances = new Set<string>(
411 providerHistory.map(
412 ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName,
413 ),
414 );
415
416 const providerInstances: Record<ProviderInstanceId, ProviderInstanceConfig> = {};
417 for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array<
418 [ProviderInstanceId, ProviderInstanceConfig]
419 >) {
420 providerInstances[instanceId] =
421 instance.enabled === undefined &&
422 HISTORY_RESTORED_DRIVERS.has(instance.driver) &&
423 usedProviderInstances.has(instanceId)
424 ? { ...instance, enabled: true }
425 : instance;
426 }
427
428 // History restores a never-configured cursor/grok/opencode slot too.
429 const legacyEntries = new Map(Object.entries(legacyProviders));
430 for (const driver of HISTORY_RESTORED_DRIVERS) {
431 if (!legacyEntries.has(driver)) legacyEntries.set(driver, {});
432 }
433 for (const [kind, blob] of legacyEntries) {
434 if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue;
435 if (Array.isArray(blob)) continue;
436 const driver = kind;
437 const instanceId = defaultInstanceIdForDriver(driver);
438 if (Object.hasOwn(providerInstances, instanceId)) continue;
439
440 const { enabled: rawEnabled, ...config } = blob as Record<string, unknown>;
441 const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined;
442 const enabled =
443 explicitEnabled ??
444 (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined);
445 const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} });
446 if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) {
447 continue;
448 }
449 providerInstances[instanceId] = {
450 driver,
451 ...(enabled === undefined ? {} : { enabled }),
452 config,
453 } satisfies ProviderInstanceConfig;
454 }
455
456 return {
457 ...settings,
458 providerInstances,
459 };
460}
461
462const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");
463
464/** ACP Registry instances reject every application text-generation operation. */
465function selectionSupportsTextGeneration(
466 settings: ServerSettings,
467 selection: ModelSelection,
468): boolean {
469 return settings.providerInstances[selection.instanceId]?.driver !== ACP_REGISTRY_DRIVER;
470}
471
472function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {
473 return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) &&
474 selectionSupportsTextGeneration(settings, settings.textGenerationModelSelection)
475 ? settings
476 : fallbackTextGenerationProvider(settings);
477}
478
479function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {
480 // A slot without an explicit instance uses its driver's default enabled state.
481 const fallback = fallbackTextGenerationModelSelection(
482 settings.providerInstances,
483 (instanceId, driver) =>
484 resolveProviderInstanceEnabled(
485 settings.providerInstances[instanceId] ?? { driver, config: {} },
486 ),
487 { defaultModelSelection: settings.defaultModelSelection },
488 );
489 return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback };
490}
491
492// Values under these keys are compared as a whole — never stripped field-by-field.
493const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
494 "backgroundActivity",
495 "automaticGitFetchInterval",
496 "providerHealthRefreshInterval",
497 "sourceControlWriterModelSelection",
498 "reviewGuideModelSelection",
499 "textGenerationModelSelection",
500 "pullRequestMergeMethod",
501]);
502
503function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
504 if (Array.isArray(current) || Array.isArray(defaults)) {
505 return Equal.equals(current, defaults) ? undefined : current;
506 }
507
508 if (
509 current !== null &&
510 defaults !== null &&
511 typeof current === "object" &&
512 typeof defaults === "object"
513 ) {
514 const currentRecord = current as Record<string, unknown>;
515 const defaultsRecord = defaults as Record<string, unknown>;
516 const next: Record<string, unknown> = {};
517
518 for (const key of Object.keys(currentRecord)) {
519 if (ATOMIC_SETTINGS_KEYS.has(key)) {
520 if (!Equal.equals(currentRecord[key], defaultsRecord[key])) {
521 next[key] = currentRecord[key];
522 }
523 } else {
524 const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]);
525 if (stripped !== undefined) {
526 next[key] = stripped;
527 }
528 }
529 }
530
531 return Object.keys(next).length > 0 ? next : undefined;
532 }
533
534 return Object.is(current, defaults) ? undefined : current;
535}
536
537const decodeProjectScriptsJson = Schema.decodeUnknownOption(
538 Schema.fromJsonString(Schema.Array(ProjectScript)),
539);
540const decodeModelSelectionJson = Schema.decodeUnknownOption(
541 Schema.fromJsonString(Schema.NullOr(ModelSelection)),
542);
543
544interface LegacyProjectSettingsRow {
545 readonly projectId: string;
546 readonly defaultModelSelection: string | null;
547 readonly defaultThreadEnvMode: string | null;
548 readonly autoPull: number;
549 readonly scripts: string;
550}
551
552/**
553 * One-time fold of the legacy per-project fields into `projectSettingsOverrides`:
554 * the three `project*Overrides` maps and the settings columns on the project
555 * aggregate. Keys already present in the generic record win. Marked with
556 * `projectSettingsFolded` so a later reset in the UI survives restarts.
557 */
558function foldLegacyProjectSettings(
559 settings: ServerSettings,
560 rows: ReadonlyArray<LegacyProjectSettingsRow>,
561): ServerSettings {
562 if (settings.projectSettingsFolded) return settings;
563 // Nothing to fold yet (fresh install): leave the marker off so the file
564 // stays sparse, and check again on the next load.
565 if (
566 rows.length === 0 &&
567 Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 &&
568 Object.keys(settings.projectAutoPullOverrides).length === 0 &&
569 Object.keys(settings.projectScriptOverrides).length === 0
570 ) {
571 return settings;
572 }
573 const entries: Record<string, ProjectSettingsOverrides> = {
574 ...settings.projectSettingsOverrides,
575 };
576 const set = <K extends keyof ProjectSettingsOverrides>(
577 projectId: string,
578 key: K,
579 value: ProjectSettingsOverrides[K] | undefined,
580 ) => {
581 if (value === undefined) return;
582 const entry = entries[projectId] ?? {};
583 if (Object.hasOwn(entry, key)) return;
584 entries[projectId] = { ...entry, [key]: value };
585 };
586 for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) {
587 set(projectId, "enableAgentBrowserAccess", value);
588 }
589 for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) {
590 set(projectId, "defaultAutoPull", value);
591 }
592 // A stored null meant "reset to machine defaults", which is now plain
593 // inheritance; the project's own aggregate scripts must not resurface.
594 const resetScripts = new Set<string>();
595 for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) {
596 if (value === null) resetScripts.add(projectId);
597 else set(projectId, "defaultProjectScripts", value);
598 }
599 for (const row of rows) {
600 const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null");
601 if (Option.isSome(model) && model.value !== null) {
602 set(row.projectId, "defaultModelSelection", model.value);
603 }
604 if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") {
605 set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode);
606 }
607 if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true);
608 const scripts = decodeProjectScriptsJson(row.scripts);
609 if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) {
610 set(row.projectId, "defaultProjectScripts", scripts.value);
611 }
612 }
613 const projectSettingsOverrides = Object.fromEntries(
614 Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0),
615 );
616 return {
617 ...settings,
618 projectSettingsOverrides,
619 projectSettingsFolded: true,
620 ...deriveLegacyProjectOverrides({ projectSettingsOverrides }),
621 };
622}
623
624const make = Effect.gen(function* () {
625 const { settingsPath } = yield* ServerConfig.ServerConfig;
626 const fs = yield* FileSystem.FileSystem;
627 const pathService = yield* Path.Path;
628 const secretStore = yield* ServerSecretStore.ServerSecretStore;
629 const sql = yield* SqlClient.SqlClient;
630 const writeSemaphore = yield* Semaphore.make(1);
631 const cacheKey = "settings" as const;
632 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
633 const startedRef = yield* Ref.make(false);
634 const startedDeferred = yield* Deferred.make<void, ServerSettingsError>();
635 const watcherScope = yield* Scope.make("sequential");
636 yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));
637
638 const emitChange = (settings: ServerSettings) =>
639 PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid);
640
641 const readConfigExists = fs.exists(settingsPath).pipe(
642 Effect.mapError(
643 (cause) =>
644 new ServerSettingsError({
645 settingsPath,
646 operation: "check-exists",
647 cause,
648 }),
649 ),
650 );
651
652 const readRawConfig = fs.readFileString(settingsPath).pipe(
653 Effect.mapError(
654 (cause) =>
655 new ServerSettingsError({
656 settingsPath,
657 operation: "read-file",
658 cause,
659 }),
660 ),
661 );
662
663 const writeSettingsAtomically = Effect.fnUntraced(
664 function* (settings: ServerSettings) {
665 const sparseSettingsJson = yield* encodeServerSettingsJson(
666 stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},
667 );
668
669 return yield* writeFileStringAtomically({
670 filePath: settingsPath,
671 contents: `${sparseSettingsJson}\n`,
672 }).pipe(
673 Effect.provideService(FileSystem.FileSystem, fs),
674 Effect.provideService(Path.Path, pathService),
675 );
676 },
677 Effect.mapError(
678 (cause) =>
679 new ServerSettingsError({
680 settingsPath,
681 operation: "write-file",
682 cause,
683 }),
684 ),
685 );
686
687 /**
688 * Migrate plaintext schema-declared credentials on load. Failed moves stay usable from
689 * the file and are retried next time; client redaction does not depend on migration.
690 */
691 const moveInlineSettingsSecrets = (settings: ServerSettings) =>
692 Effect.gen(function* () {
693 const pending: SettingsSecret[] = [];
694 mapSettingsSecrets(settings, (secret) => {
695 if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) {
696 pending.push(secret);
697 }
698 return secret;
699 });
700 const moved = new Set<string>();
701 for (const secret of pending) {
702 const stored = yield* secretStore
703 .set(secret.secretName, textEncoder.encode(secret.value))
704 .pipe(
705 Effect.as(true),
706 Effect.catch(() =>
707 Effect.logWarning("failed to move a settings secret into the secret store").pipe(
708 Effect.as(false),
709 ),
710 ),
711 );
712 if (stored) moved.add(secret.secretName);
713 }
714 if (moved.size === 0) return settings;
715 return mapSettingsSecrets(settings, (secret) =>
716 moved.has(secret.secretName) ? { value: "", redacted: true } : secret,
717 );
718 });
719
720 const loadSettingsFromDisk = Effect.gen(function* () {
721 let settings = DEFAULT_SERVER_SETTINGS;
722 let legacyProviders: Readonly<Record<string, unknown>> | undefined;
723 let legacySourceControl: Parameters<typeof migrateLegacySourceControlSettings>[1] = {};
724 // A file that failed to decode must stay on disk for the user to repair;
725 // the fold below only writes when it started from the file's real contents.
726 let settingsFileTrusted = true;
727
728 if (yield* readConfigExists) {
729 const raw = yield* readRawConfig;
730 const decoded = decodeServerSettingsJsonExit(raw);
731 const legacySettings = decodeLegacyProviderSettingsJsonExit(raw);
732 if (legacySettings._tag === "Success") {
733 legacyProviders = legacySettings.value.providers;
734 legacySourceControl = {
735 bitbucket: legacySettings.value.bitbucket,
736 github: legacySettings.value.github,
737 };
738 }
739 if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
740 const failure = decoded._tag === "Failure" ? decoded : legacySettings;
741 settingsFileTrusted = false;
742 // The file may hold an opt-out we can no longer read; never resume threads on a guess.
743 settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false };
744 if (failure._tag === "Failure") {
745 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
746 path: settingsPath,
747 issues: Cause.pretty(failure.cause),
748 cause: failure.cause,
749 });
750 }
751 } else {
752 settings = decoded.value;
753 }
754 }
755
756 const providerHistory = yield* sql<{
757 readonly providerName: string;
758 readonly providerInstanceId: string | null;
759 }>`
760 SELECT DISTINCT
761 provider_name AS "providerName",
762 provider_instance_id AS "providerInstanceId"
763 FROM projection_thread_sessions
764 WHERE provider_name IN ('cursor', 'grok', 'opencode')
765 UNION
766 SELECT DISTINCT
767 provider_name AS "providerName",
768 provider_instance_id AS "providerInstanceId"
769 FROM provider_session_runtime
770 WHERE provider_name IN ('cursor', 'grok', 'opencode')
771 `.pipe(
772 Effect.mapError(
773 (cause) =>
774 new ServerSettingsError({
775 settingsPath,
776 operation: "read-provider-history",
777 cause,
778 }),
779 ),
780 );
781
782 const legacyProjectRows =
783 settings.projectSettingsFolded || !settingsFileTrusted
784 ? []
785 : yield* sql<LegacyProjectSettingsRow>`
786 SELECT
787 project_id AS "projectId",
788 default_model_selection_json AS "defaultModelSelection",
789 default_thread_env_mode AS "defaultThreadEnvMode",
790 auto_pull AS "autoPull",
791 scripts_json AS "scripts"
792 FROM projection_projects
793 WHERE deleted_at IS NULL
794 `.pipe(
795 Effect.mapError(
796 (cause) =>
797 new ServerSettingsError({
798 settingsPath,
799 operation: "read-project-settings",
800 cause,
801 }),
802 ),
803 );
804
805 const loaded = foldProviderInstanceEnabledFlags(
806 legacyProviders === undefined
807 ? settings
808 : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory),
809 );
810 const folded = settingsFileTrusted
811 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
812 : loaded;
813 // Copied, not moved: the old entry is removed only after the new file is written. A token
814 // that cannot be read or copied leaves the retired keys in place, so the migration runs again
815 // on the next load instead of dropping it.
816 const legacySecretsCopied = yield* Effect.forEach(
817 settingsFileTrusted
818 ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves
819 : [],
820 (move) =>
821 secretStore.get(move.from).pipe(
822 Effect.flatMap((secret) =>
823 Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value),
824 ),
825 Effect.as(true),
826 Effect.catch(() =>
827 Effect.logWarning("failed to copy a source control secret to its new name", {
828 secretName: move.to,
829 }).pipe(Effect.as(false)),
830 ),
831 ),
832 ).pipe(Effect.map((copied) => copied.every(Boolean)));
833 const hasLegacySourceControl =
834 settingsFileTrusted &&
835 legacySecretsCopied &&
836 (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined);
837 const sourceControlMigration = hasLegacySourceControl
838 ? migrateLegacySourceControlSettings(folded, legacySourceControl)
839 : { settings: folded, secretMoves: [] };
840 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
841 const migrated = settingsFileTrusted
842 ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings)
843 : folded;
844 // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten
845 // once so the migrated values persist and the old keys disappear.
846 if (
847 migrated !== loaded ||
848 (settingsFileTrusted && legacyProviders !== undefined) ||
849 hasLegacySourceControl
850 ) {
851 yield* writeSettingsAtomically(migrated);
852 for (const move of sourceControlMigration.secretMoves) {
853 yield* secretStore.remove(move.from).pipe(Effect.ignore);
854 }
855 }
856 return migrated;
857 });
858
859 // A failed read is not kept: the next read retries instead of replaying the failure.
860 const settingsCache = yield* Cache.makeWith<typeof cacheKey, ServerSettings, ServerSettingsError>(
861 () => loadSettingsFromDisk,
862 {
863 capacity: 1,
864 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
865 },
866 );
867
868 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
869
870 const materializeSettingsSecrets = (
871 settings: ServerSettings,
872 ): Effect.Effect<ServerSettings, ServerSettingsError> =>
873 Effect.gen(function* () {
874 const storedSecrets = new Map<string, string>();
875 const pending = new Map<string, SettingsSecret>();
876 mapSettingsSecrets(settings, (secret) => {
877 if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret);
878 return secret;
879 });
880 for (const secret of pending.values()) {
881 const value = yield* secretStore.get(secret.secretName).pipe(
882 Effect.mapError(
883 (cause) =>
884 new ServerSettingsError({
885 settingsPath,
886 operation: "read-secret",
887 providerInstanceId: secret.providerInstanceId,
888 environmentVariable: secret.environmentVariable,
889 cause,
890 }),
891 ),
892 );
893 storedSecrets.set(
894 secret.secretName,
895 Option.isSome(value) ? textDecoder.decode(value.value) : "",
896 );
897 }
898 return mapSettingsSecrets(settings, (secret) => ({
899 value:
900 secret.sensitive && secret.redacted
901 ? (storedSecrets.get(secret.secretName) ?? "")
902 : secret.value,
903 redacted: secret.sensitive && secret.kind === "environment" && secret.redacted,
904 }));
905 });
906
907 const materializeChanges = (changes: Stream.Stream<ServerSettings>) =>
908 changes.pipe(
909 Stream.mapEffect((settings) =>
910 materializeSettingsSecrets(settings).pipe(
911 Effect.catch((error: ServerSettingsError) =>
912 Effect.logWarning("failed to materialize settings secrets", {
913 operation: error.operation,
914 providerInstanceId: error.providerInstanceId,
915 environmentVariable: error.environmentVariable,
916 cause: error.cause,
917 }).pipe(Effect.as(settings)),
918 ),
919 ),
920 ),
921 Stream.map(resolveTextGenerationProvider),
922 );
923
924 type SecretChange = {
925 readonly secretName: string;
926 readonly providerInstanceId?: string;
927 readonly environmentVariable?: string;
928 } & (
929 | { readonly kind: "write"; readonly value: Uint8Array }
930 | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" }
931 );
932
933 const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) =>
934 Effect.sync(() => {
935 const previous = new Map<string, SettingsSecret>();
936 mapSettingsSecrets(current, (secret) => {
937 // Duplicate environment names follow the provider's last-value-wins behavior.
938 previous.set(secret.secretName, secret);
939 return secret;
940 });
941 const changes: SecretChange[] = [];
942 const nextSecretNames = new Set<string>();
943 const settings = mapSettingsSecrets(next, (secret) => {
944 const metadata = {
945 secretName: secret.secretName,
946 ...(secret.providerInstanceId !== undefined
947 ? { providerInstanceId: secret.providerInstanceId }
948 : {}),
949 ...(secret.environmentVariable !== undefined
950 ? { environmentVariable: secret.environmentVariable }
951 : {}),
952 };
953 if (!secret.sensitive) {
954 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
955 return { value: secret.value, redacted: false };
956 }
957 const old = previous.get(secret.secretName);
958 const inlineValue =
959 old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined;
960 const value = secret.redacted ? inlineValue : secret.value;
961 if (value === undefined) {
962 nextSecretNames.add(secret.secretName);
963 return { value: "", redacted: true };
964 }
965 if (value.length === 0) {
966 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
967 return { value: "", redacted: false };
968 }
969 nextSecretNames.add(secret.secretName);
970 changes.push({ ...metadata, kind: "write", value: textEncoder.encode(value) });
971 return { value: "", redacted: true };
972 });
973 for (const secret of previous.values()) {
974 if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue;
975 if (changes.some((change) => change.secretName === secret.secretName)) continue;
976 changes.push({
977 kind: "remove",
978 secretName: secret.secretName,
979 operation: "remove-stale-secret",
980 ...(secret.providerInstanceId !== undefined
981 ? { providerInstanceId: secret.providerInstanceId }
982 : {}),
983 ...(secret.environmentVariable !== undefined
984 ? { environmentVariable: secret.environmentVariable }
985 : {}),
986 });
987 }
988 return { settings, changes };
989 });
990
991 const rollbackSettingsSecretWrites = (
992 writes: ReadonlyArray<{
993 readonly secretName: string;
994 readonly previousValue: Option.Option<Uint8Array>;
995 readonly providerInstanceId?: string;
996 readonly environmentVariable?: string;
997 }>,
998 ) =>
999 Effect.forEach(
1000 writes.toReversed(),
1001 (write) =>
1002 (Option.isSome(write.previousValue)
1003 ? secretStore.set(write.secretName, write.previousValue.value)
1004 : secretStore.remove(write.secretName)
1005 ).pipe(
1006 Effect.catch((cause) =>
1007 Effect.logWarning("failed to roll back settings secret", {
1008 providerInstanceId: write.providerInstanceId,
1009 environmentVariable: write.environmentVariable,
1010 cause,
1011 }),
1012 ),
1013 ),
1014 { discard: true },
1015 );
1016
1017 const applySettingsSecretChanges = (changes: ReadonlyArray<SecretChange>) => {
1018 const applied: Array<{
1019 readonly secretName: string;
1020 readonly previousValue: Option.Option<Uint8Array>;
1021 readonly providerInstanceId?: string;
1022 readonly environmentVariable?: string;
1023 }> = [];
1024 const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied));
1025 return Effect.forEach(
1026 changes,
1027 (change) =>
1028 Effect.gen(function* () {
1029 const previousValue = yield* secretStore.get(change.secretName).pipe(
1030 Effect.mapError(
1031 (cause) =>
1032 new ServerSettingsError({
1033 settingsPath,
1034 operation: "read-secret",
1035 providerInstanceId: change.providerInstanceId,
1036 environmentVariable: change.environmentVariable,
1037 cause,
1038 }),
1039 ),
1040 );
1041 // A store operation may mutate before reporting an error (for example chmod after rename).
1042 applied.push({ ...change, previousValue });
1043 yield* (
1044 change.kind === "write"
1045 ? secretStore.set(change.secretName, change.value)
1046 : secretStore.remove(change.secretName)
1047 ).pipe(
1048 Effect.mapError(
1049 (cause) =>
1050 new ServerSettingsError({
1051 settingsPath,
1052 operation: change.kind === "write" ? "write-secret" : change.operation,
1053 providerInstanceId: change.providerInstanceId,
1054 environmentVariable: change.environmentVariable,
1055 cause,
1056 }),
1057 ),
1058 );
1059 }),
1060 { discard: true },
1061 ).pipe(
1062 Effect.tapError(() => rollback),
1063 Effect.as(rollback),
1064 );
1065 };
1066
1067 const updateAndPersistSettings = (
1068 update: (current: ServerSettings) => Effect.Effect<ServerSettings, ServerSettingsError>,
1069 ): Effect.Effect<ServerSettings, ServerSettingsError> =>
1070 writeSemaphore.withPermits(1)(
1071 Effect.gen(function* () {
1072 const current = yield* getSettingsFromCache;
1073 const updated = yield* update(current);
1074 const persisted = yield* persistSettingsSecrets(current, updated);
1075 const next = yield* normalizeServerSettings(persisted.settings);
1076 const materialized = yield* Effect.uninterruptibleMask(() =>
1077 Effect.gen(function* () {
1078 const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes);
1079 const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next));
1080 if (Exit.isFailure(materializedExit)) {
1081 yield* rollbackSecretChanges;
1082 return yield* Effect.failCause(materializedExit.cause);
1083 }
1084 const writeExit = yield* Effect.exit(writeSettingsAtomically(next));
1085 if (Exit.isFailure(writeExit)) {
1086 yield* rollbackSecretChanges;
1087 return yield* Effect.failCause(writeExit.cause);
1088 }
1089 return materializedExit.value;
1090 }),
1091 );
1092 yield* Cache.set(settingsCache, cacheKey, next);
1093 yield* emitChange(next);
1094 return resolveTextGenerationProvider(materialized);
1095 }),
1096 );
1097
1098 const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) =>
1099 writeSemaphore.withPermits(1)(
1100 getSettingsFromCache.pipe(
1101 Effect.flatMap(materializeSettingsSecrets),
1102 Effect.map(resolveTextGenerationProvider),
1103 Effect.flatMap(use),
1104 ),
1105 );
1106
1107 const revalidateAndEmit = writeSemaphore.withPermits(1)(
1108 Effect.gen(function* () {
1109 yield* Cache.invalidate(settingsCache, cacheKey);
1110 const settings = yield* getSettingsFromCache;
1111 yield* emitChange(settings);
1112 }),
1113 );
1114
1115 const watchFileChanges = (filePath: string) => {
1116 const directory = pathService.dirname(filePath);
1117 const fileName = pathService.basename(filePath);
1118 const resolvedFilePath = pathService.resolve(filePath);
1119 return fs
1120 .watch(directory)
1121 .pipe(
1122 Stream.filter(
1123 (event) =>
1124 event.path === fileName ||
1125 event.path === filePath ||
1126 pathService.resolve(directory, event.path) === resolvedFilePath,
1127 ),
1128 );
1129 };
1130
1131 const startWatcher = Effect.gen(function* () {
1132 const settingsDir = pathService.dirname(settingsPath);
1133
1134 yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe(
1135 Effect.mapError(
1136 (cause) =>
1137 new ServerSettingsError({
1138 settingsPath,
1139 operation: "prepare-directory",
1140 cause,
1141 }),
1142 ),
1143 );
1144
1145 const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));
1146
1147 // A symlinked settings file is rewritten in its destination's directory,
1148 // which a watch on the link's directory never sees. The link is resolved
1149 // again whenever it changes, so repointing it moves the watch along.
1150 const watchLinkTarget = Effect.gen(function* () {
1151 const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe(
1152 Effect.provideService(FileSystem.FileSystem, fs),
1153 Effect.provideService(Path.Path, pathService),
1154 );
1155 if (linkTargetPath === pathService.resolve(settingsPath)) {
1156 return Option.none<string>();
1157 }
1158 yield* fs
1159 .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true })
1160 .pipe(Effect.ignore({ log: true }));
1161 return Option.some(linkTargetPath);
1162 }).pipe(Effect.orElseSucceed(() => Option.none<string>()));
1163
1164 const initialLinkTarget = yield* watchLinkTarget;
1165 const linkTargetEvents = Stream.make(initialLinkTarget).pipe(
1166 Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))),
1167 Stream.changes,
1168 Stream.switchMap(
1169 Option.match({
1170 onNone: () => Stream.empty,
1171 onSome: (linkTargetPath) =>
1172 watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })),
1173 }),
1174 ),
1175 );
1176
1177 // Debounce watch events so the file is fully written before we read it.
1178 // Editors emit multiple events per save (truncate, write, rename) and
1179 // `fs.watch` can fire before the content has been flushed to disk.
1180 const debouncedSettingsEvents = Stream.merge(
1181 watchFileChanges(settingsPath),
1182 linkTargetEvents,
1183 ).pipe(Stream.debounce(Duration.millis(100)));
1184
1185 yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe(
1186 Effect.ignoreCause({ log: true }),
1187 Effect.forkIn(watcherScope),
1188 Effect.asVoid,
1189 );
1190 });
1191
1192 const start = Effect.gen(function* () {
1193 const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]);
1194 if (!shouldStart) {
1195 return yield* Deferred.await(startedDeferred);
1196 }
1197
1198 const startup = Effect.gen(function* () {
1199 yield* startWatcher;
1200 yield* Cache.invalidate(settingsCache, cacheKey);
1201 yield* getSettingsFromCache;
1202 });
1203
1204 const startupExit = yield* Effect.exit(startup);
1205 if (startupExit._tag === "Failure") {
1206 yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie);
1207 return yield* Effect.failCause(startupExit.cause);
1208 }
1209
1210 yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie);
1211 });
1212
1213 return {
1214 start,
1215 ready: Deferred.await(startedDeferred),
1216 getSettings: getSettingsFromCache.pipe(
1217 Effect.flatMap(materializeSettingsSecrets),
1218 Effect.map(resolveTextGenerationProvider),
1219 ),
1220 updateSettings: (patch) =>
1221 updateAndPersistSettings((current) =>
1222 Effect.succeed(applyServerSettingsPatch(current, patch)),
1223 ),
1224 updateProviderInstance: (mutation, patch = {}) =>
1225 updateAndPersistSettings((current) =>
1226 Effect.gen(function* () {
1227 yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath);
1228 const patched = applyServerSettingsPatch(current, patch);
1229 return applyProviderInstanceMutation(patched, mutation);
1230 }),
1231 ),
1232 withSettingsSnapshot,
1233 get streamChanges() {
1234 return materializeChanges(Stream.fromPubSub(changesPubSub));
1235 },
1236 get subscribeChanges() {
1237 return PubSub.subscribe(changesPubSub).pipe(
1238 Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))),
1239 );
1240 },
1241 } satisfies ServerSettingsService["Service"];
1242});
1243
1244export const layer = Layer.effect(ServerSettingsService, make);