Open History to see the last commit
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ModelSelection,
4 ProjectId,
5 ProjectScript,
6 keepUnknownOverrideKeys,
7 ProjectSettingsOverrides,
8 type ProviderInstanceConfig,
9 type ProviderInstanceMutation,
10 defaultInstanceIdForDriver,
11 isProviderDriverKind,
12 ProviderDriverKind,
13 ProviderInstanceId,
14 resolveProviderInstanceEnabled,
15 ResponseStreamingMode,
16 ServerSettings,
17 ServerSettingsError,
18 type ServerSettingsPatch,
19 type ManagedPolicySummary,
20 globalIdleCompaction,
21 isSecretReference,
22 type SecretReference,
23 withIdleCompaction,
24} from "@t3tools/contracts";
25import * as Cache from "effect/Cache";
26import * as Cause from "effect/Cause";
27import * as Context from "effect/Context";
28import * as Deferred from "effect/Deferred";
29import * as Duration from "effect/Duration";
30import * as Equal from "effect/Equal";
31import * as Effect from "effect/Effect";
32import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
33import * as Exit from "effect/Exit";
34import * as FileSystem from "effect/FileSystem";
35import * as Layer from "effect/Layer";
36import * as Option from "effect/Option";
37import * as Path from "effect/Path";
38import * as PubSub from "effect/PubSub";
39import * as Ref from "effect/Ref";
40import * as Schema from "effect/Schema";
41import * as SchemaTransformation from "effect/SchemaTransformation";
42import * as Semaphore from "effect/Semaphore";
43import * as Scope from "effect/Scope";
44import * as Stream from "effect/Stream";
45import * as SqlClient from "effect/sql/SqlClient";
46import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
47import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
48import * as ServerConfig from "./config.ts";
49import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
50import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
51import {
52 applyServerSettingsPatch,
53 deriveLegacyProjectOverrides,
54 fallbackTextGenerationModelSelection,
55 isModelSelectionProviderEnabled,
56} from "@t3tools/shared/serverSettings";
57import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
58import * as ManagedPolicy from "./policy/ManagedPolicy.ts";
59import { secretProviderDefinitions } from "./secrets/secretProviderDefinitions.ts";
60import {
61 readSecretFieldValues,
62 sourceControlHostSecretName,
63} from "./sourceControl/sourceControlHostSecrets.ts";
64
65import {
66 mapSettingsSecrets,
67 mapSettingsSecretValues,
68 withoutRemovedSecretProviderReferences,
69 SECRET_REDACTED,
70 type SettingsSecret,
71} from "./settingsSecrets.ts";
72
73export {
74 resolveReviewGuideModelSelection,
75 resolveSourceControlWriterModelSelection,
76} from "@t3tools/shared/serverSettings";
77
78const encodeServerSettings = Schema.encodeEffect(ServerSettings);
79const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
80const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
81
82const textEncoder = new TextEncoder();
83const textDecoder = new TextDecoder();
84
85// Conflicting flags resolve to false to preserve user disables.
86const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => {
87 let changed = false;
88 const providerInstances: Record<string, ProviderInstanceConfig> = {};
89 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
90 const config = instance.config;
91 // Leave malformed flags for driver schema validation rather than silently repairing them.
92 if (
93 config === null ||
94 typeof config !== "object" ||
95 Array.isArray(config) ||
96 typeof (config as { readonly enabled?: unknown }).enabled !== "boolean"
97 ) {
98 providerInstances[instanceId] = instance;
99 continue;
100 }
101 const { enabled: configEnabled, ...restConfig } = config as Record<string, unknown> & {
102 readonly enabled: boolean;
103 };
104 const resolved =
105 instance.enabled === false || configEnabled === false
106 ? false
107 : (instance.enabled ?? configEnabled);
108 changed = true;
109 providerInstances[instanceId] = {
110 ...instance,
111 enabled: resolved,
112 config: restConfig,
113 } satisfies ProviderInstanceConfig;
114 }
115 if (!changed) {
116 return settings;
117 }
118 return {
119 ...settings,
120 providerInstances: providerInstances as ServerSettings["providerInstances"],
121 };
122};
123
124const normalizeServerSettings = (
125 settings: ServerSettings,
126): Effect.Effect<ServerSettings, ServerSettingsError> =>
127 encodeServerSettings(settings).pipe(
128 Effect.flatMap(decodeServerSettings),
129 Effect.map(foldProviderInstanceEnabledFlags),
130 Effect.map((next) => withIdleCompaction(next, globalIdleCompaction(next))),
131 Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })),
132 Effect.mapError(
133 (cause) =>
134 new ServerSettingsError({
135 settingsPath: "<memory>",
136 operation: "normalize",
137 cause,
138 }),
139 ),
140 );
141
142export function redactServerSettingsForClient(
143 settings: ServerSettings,
144 client: { readonly secretReferences: boolean },
145): ServerSettings {
146 const redacted = mapSettingsSecrets(settings, (secret) =>
147 secret.sensitive
148 ? { value: "", redacted: secret.redacted || secret.value.length > 0 }
149 : { value: secret.value, redacted: false },
150 );
151 // Older clients decode secret fields as strings and fail on a reference, so they see a saved secret.
152 const shaped = client.secretReferences
153 ? redacted
154 : mapSettingsSecretValues(redacted, (value) =>
155 isSecretReference(value) ? SECRET_REDACTED : value,
156 );
157 return { ...shaped, secretProviders: withoutUnknownSecretProviderConfigs(shaped) };
158}
159
160/** A client shown a reference as a saved secret sends that marker back; it keeps the reference. */
161function withEchoedSecretReferences(current: ServerSettings, next: ServerSettings): ServerSettings {
162 const references = new Map<string, SecretReference>();
163 mapSettingsSecretValues(current, (value, secretName) => {
164 if (isSecretReference(value)) references.set(secretName, value);
165 return value;
166 });
167 if (references.size === 0) return next;
168 return mapSettingsSecretValues(next, (value, secretName) =>
169 value === SECRET_REDACTED ? (references.get(secretName) ?? value) : value,
170 );
171}
172
173// A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server.
174function withoutUnknownSecretProviderConfigs(
175 settings: ServerSettings,
176): ServerSettings["secretProviders"] {
177 return Object.fromEntries(
178 Object.entries(settings.secretProviders).map(([id, entry]) => {
179 if (secretProviderConfigGuards.has(entry.kind)) return [id, entry];
180 const { config: _config, ...envelope } = entry;
181 return [id, envelope];
182 }),
183 ) as ServerSettings["secretProviders"];
184}
185
186export function applyProviderInstanceMutation(
187 settings: ServerSettings,
188 mutation: ProviderInstanceMutation,
189): ServerSettings {
190 const providerInstances = { ...settings.providerInstances };
191 if (mutation.operation === "upsert" || mutation.operation === "create") {
192 providerInstances[mutation.instanceId] = mutation.instance;
193 } else {
194 delete providerInstances[mutation.instanceId];
195 }
196 return { ...settings, providerInstances };
197}
198
199const secretProviderConfigGuards = new Map(
200 secretProviderDefinitions.definitions.map((definition) => [
201 definition.kind as string,
202 Schema.is(definition.configSchema),
203 ]),
204);
205
206// Rejects a kind this server has not loaded rather than storing a config it cannot check.
207function ensureSecretProviderWritesAllowed(
208 patch: ServerSettingsPatch,
209 settingsPath: string,
210): Effect.Effect<void, ServerSettingsError> {
211 for (const [id, entry] of Object.entries(patch.secretProviders ?? {})) {
212 if (entry === null) continue;
213 const isConfig = secretProviderConfigGuards.get(entry.kind);
214 const problem =
215 isConfig === undefined
216 ? `Unsupported secret provider kind: ${entry.kind}.`
217 : isConfig(entry.config)
218 ? undefined
219 : `Invalid ${entry.kind} config for secret provider ${id}.`;
220 if (problem !== undefined) {
221 return Effect.fail(
222 new ServerSettingsError({
223 settingsPath,
224 operation: "write-secret-provider",
225 cause: new Error(problem),
226 }),
227 );
228 }
229 }
230 return Effect.void;
231}
232
233function ensureProviderInstanceMutationAllowed(
234 settings: ServerSettings,
235 mutation: ProviderInstanceMutation,
236 settingsPath: string,
237): Effect.Effect<void, ServerSettingsError> {
238 if (
239 mutation.operation === "create" &&
240 Object.hasOwn(settings.providerInstances, mutation.instanceId)
241 ) {
242 return Effect.fail(
243 new ServerSettingsError({
244 settingsPath,
245 operation: "create-provider-instance",
246 providerInstanceId: mutation.instanceId,
247 }),
248 );
249 }
250 return Effect.void;
251}
252
253export class ServerSettingsService extends Context.Service<
254 ServerSettingsService,
255 {
256 readonly start: Effect.Effect<void, ServerSettingsError>;
257
258 readonly ready: Effect.Effect<void, ServerSettingsError>;
259
260 /** The administrator's policy as clients see it; absent when unmanaged. */
261 readonly managedPolicy?: ManagedPolicySummary | undefined;
262
263 readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>;
264
265 readonly updateSettings: (
266 patch: ServerSettingsPatch,
267 ) => Effect.Effect<ServerSettings, ServerSettingsError>;
268
269 readonly updateProviderInstance: (
270 mutation: ProviderInstanceMutation,
271 patch?: ServerSettingsPatch,
272 ) => Effect.Effect<ServerSettings, ServerSettingsError>;
273
274 readonly withSettingsSnapshot: <A, E, R>(
275 use: (settings: ServerSettings) => Effect.Effect<A, E, R>,
276 ) => Effect.Effect<A, E | ServerSettingsError, R>;
277
278 readonly streamChanges: Stream.Stream<ServerSettings>;
279
280 /** Subscribe before reading a snapshot to retain changes before lazy stream startup. */
281 readonly subscribeChanges: Effect.Effect<Stream.Stream<ServerSettings>, never, Scope.Scope>;
282 }
283>()("t3/serverSettings/ServerSettingsService") {
284 static readonly layerTest = (
285 overrides: DeepPartial<ServerSettings> = {},
286 managedPolicy?: ManagedPolicySummary,
287 ) => layerTest(overrides, managedPolicy);
288}
289
290const makeTest = Effect.fn("serverSettings.makeTest")(function* (
291 overrides: DeepPartial<ServerSettings> = {},
292 managedPolicy?: ManagedPolicySummary,
293) {
294 const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } =
295 overrides;
296 const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
297 const initialSettings = yield* normalizeServerSettings({
298 ...merged,
299 ...(automaticGitFetchInterval !== undefined
300 ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }
301 : {}),
302 ...(providerHealthRefreshInterval !== undefined
303 ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration }
304 : {}),
305 });
306 const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);
307 const writeSemaphore = yield* Semaphore.make(1);
308 const getSettings = Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider));
309
310 const updateTestSettings = (
311 update: (current: ServerSettings) => Effect.Effect<ServerSettings, ServerSettingsError>,
312 ): Effect.Effect<ServerSettings, ServerSettingsError> =>
313 writeSemaphore.withPermits(1)(
314 Ref.get(currentSettingsRef).pipe(
315 Effect.flatMap(update),
316 Effect.flatMap(normalizeServerSettings),
317 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
318 Effect.map(resolveTextGenerationProvider),
319 ),
320 );
321
322 return {
323 managedPolicy,
324 start: Effect.void,
325 ready: Effect.void,
326 getSettings,
327 updateSettings: (patch) =>
328 updateTestSettings((currentSettings) =>
329 Effect.succeed(applyServerSettingsPatch(currentSettings, patch)),
330 ),
331 updateProviderInstance: (mutation, patch = {}) =>
332 updateTestSettings((currentSettings) =>
333 Effect.gen(function* () {
334 yield* ensureProviderInstanceMutationAllowed(currentSettings, mutation, "test settings");
335 const patched = applyServerSettingsPatch(currentSettings, patch);
336 return applyProviderInstanceMutation(patched, mutation);
337 }),
338 ),
339 withSettingsSnapshot: (use) =>
340 writeSemaphore.withPermits(1)(getSettings.pipe(Effect.flatMap(use))),
341 streamChanges: Stream.empty,
342 subscribeChanges: Effect.succeed(Stream.empty),
343 } satisfies ServerSettingsService["Service"];
344});
345
346/** Test settings; `managedPolicy` only tells consumers a policy applies, it does not lock values. */
347export const layerTest = (
348 overrides: DeepPartial<ServerSettings> = {},
349 managedPolicy?: ManagedPolicySummary,
350) => Layer.effect(ServerSettingsService, makeTest(overrides, managedPolicy));
351
352// Accept retired token delivery only on load so one old value cannot reset all settings.
353const PersistedResponseStreamingMode = Schema.Union([
354 ResponseStreamingMode,
355 Schema.Literal("token"),
356]).pipe(
357 Schema.decodeTo(
358 ResponseStreamingMode,
359 SchemaTransformation.transform({
360 decode: (mode) => (mode === "token" ? "paragraph" : mode),
361 encode: (mode) => mode,
362 }),
363 ),
364);
365const PersistedServerSettings = Schema.Struct({
366 ...ServerSettings.fields,
367 responseStreamingMode: PersistedResponseStreamingMode.pipe(
368 Schema.withDecodingDefault(Effect.succeed("paragraph" as const)),
369 ),
370 projectSettingsOverrides: Schema.Record(
371 ProjectId,
372 keepUnknownOverrideKeys(
373 Schema.Struct({
374 ...ProjectSettingsOverrides.fields,
375 responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode),
376 }),
377 ),
378 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
379});
380// Parse first and decode second, so a managed policy can fill its defaults in between.
381const decodeSettingsFileJsonExit = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown));
382const decodePersistedServerSettingsExit = Schema.decodeUnknownExit(PersistedServerSettings);
383const LegacyProviderSettingsJson = fromLenientJson(
384 Schema.Struct({
385 providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
386 bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
387 github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
388 }),
389);
390const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson);
391
392const LEGACY_BITBUCKET_SECRET_NAMES: Readonly<Record<string, string>> = {
393 accessToken: "bitbucket-access-token",
394 apiToken: "bitbucket-api-token",
395};
396const legacyGitHubTokenSecretName = (host: string) =>
397 `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`;
398
399function migrateLegacySourceControlSettings(
400 settings: ServerSettings,
401 legacy: {
402 readonly bitbucket?: Readonly<Record<string, unknown>> | undefined;
403 readonly github?: Readonly<Record<string, unknown>> | undefined;
404 },
405): {
406 readonly settings: ServerSettings;
407 readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>;
408} {
409 const sourceControlHosts = { ...settings.sourceControlHosts };
410 const secretMoves: Array<{ readonly from: string; readonly to: string }> = [];
411 if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) {
412 sourceControlHosts.bitbucket = legacy.bitbucket;
413 for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) {
414 if (legacy.bitbucket[field] !== SECRET_REDACTED) continue;
415 secretMoves.push({
416 from,
417 to: sourceControlHostSecretName({ kind: "bitbucket", field, serverHost: null }),
418 });
419 }
420 }
421 if (legacy.github !== undefined && sourceControlHosts.github === undefined) {
422 sourceControlHosts.github = legacy.github;
423 for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) {
424 if (serverHost === null || value !== SECRET_REDACTED) continue;
425 secretMoves.push({
426 from: legacyGitHubTokenSecretName(serverHost),
427 to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }),
428 });
429 }
430 }
431 return { settings: { ...settings, sourceControlHosts }, secretMoves };
432}
433
434const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set(
435 ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)),
436);
437
438function migrateLegacyProviderSettings(
439 settings: ServerSettings,
440 legacyProviders: Readonly<Record<string, unknown>>,
441 providerHistory: ReadonlyArray<{
442 readonly providerName: string;
443 readonly providerInstanceId: string | null;
444 }>,
445): ServerSettings {
446 const usedProviders = new Set<string>(providerHistory.map(({ providerName }) => providerName));
447 const usedProviderInstances = new Set<string>(
448 providerHistory.map(
449 ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName,
450 ),
451 );
452
453 const providerInstances: Record<ProviderInstanceId, ProviderInstanceConfig> = {};
454 for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array<
455 [ProviderInstanceId, ProviderInstanceConfig]
456 >) {
457 providerInstances[instanceId] =
458 instance.enabled === undefined &&
459 HISTORY_RESTORED_DRIVERS.has(instance.driver) &&
460 usedProviderInstances.has(instanceId)
461 ? { ...instance, enabled: true }
462 : instance;
463 }
464
465 const legacyEntries = new Map(Object.entries(legacyProviders));
466 for (const driver of HISTORY_RESTORED_DRIVERS) {
467 if (!legacyEntries.has(driver)) legacyEntries.set(driver, {});
468 }
469 for (const [kind, blob] of legacyEntries) {
470 if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue;
471 if (Array.isArray(blob)) continue;
472 const driver = kind;
473 const instanceId = defaultInstanceIdForDriver(driver);
474 if (Object.hasOwn(providerInstances, instanceId)) continue;
475
476 const { enabled: rawEnabled, ...config } = blob as Record<string, unknown>;
477 const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined;
478 const enabled =
479 explicitEnabled ??
480 (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined);
481 const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} });
482 if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) {
483 continue;
484 }
485 providerInstances[instanceId] = {
486 driver,
487 ...(enabled === undefined ? {} : { enabled }),
488 config,
489 } satisfies ProviderInstanceConfig;
490 }
491
492 return {
493 ...settings,
494 providerInstances,
495 };
496}
497
498const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");
499
500function selectionSupportsTextGeneration(
501 settings: ServerSettings,
502 selection: ModelSelection,
503): boolean {
504 return settings.providerInstances[selection.instanceId]?.driver !== ACP_REGISTRY_DRIVER;
505}
506
507type DriverAllowed = (driver: ProviderDriverKind) => boolean;
508const anyDriver: DriverAllowed = () => true;
509
510function resolveTextGenerationProvider(
511 settings: ServerSettings,
512 isDriverAllowed: DriverAllowed = anyDriver,
513): ServerSettings {
514 const selection = settings.textGenerationModelSelection;
515 return isModelSelectionProviderEnabled(settings, selection) &&
516 selectionSupportsTextGeneration(settings, selection) &&
517 isDriverAllowed(
518 settings.providerInstances[selection.instanceId]?.driver ??
519 ProviderDriverKind.make(selection.instanceId),
520 )
521 ? settings
522 : fallbackTextGenerationProvider(settings, isDriverAllowed);
523}
524
525function fallbackTextGenerationProvider(
526 settings: ServerSettings,
527 isDriverAllowed: DriverAllowed,
528): ServerSettings {
529 const fallback = fallbackTextGenerationModelSelection(
530 settings.providerInstances,
531 (instanceId, driver) =>
532 // A default instance id can be configured with another driver.
533 isDriverAllowed(settings.providerInstances[instanceId]?.driver ?? driver) &&
534 resolveProviderInstanceEnabled(
535 settings.providerInstances[instanceId] ?? { driver, config: {} },
536 ),
537 { defaultModelSelection: settings.defaultModelSelection },
538 );
539 return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback };
540}
541
542const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
543 "backgroundActivity",
544 "automaticGitFetchInterval",
545 "providerHealthRefreshInterval",
546 "sourceControlWriterModelSelection",
547 "reviewGuideModelSelection",
548 "textGenerationModelSelection",
549 "fallbackModelSelection",
550 "pullRequestMergeMethod",
551 // Empty by default, these hold one whole value, which a managed policy can default.
552 ...Object.entries(DEFAULT_SERVER_SETTINGS).flatMap(([key, value]) =>
553 value === null ? [key] : [],
554 ),
555]);
556
557function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
558 if (Array.isArray(current) || Array.isArray(defaults)) {
559 return Equal.equals(current, defaults) ? undefined : current;
560 }
561
562 if (
563 current !== null &&
564 defaults !== null &&
565 typeof current === "object" &&
566 typeof defaults === "object"
567 ) {
568 const currentRecord = current as Record<string, unknown>;
569 const defaultsRecord = defaults as Record<string, unknown>;
570 const next: Record<string, unknown> = {};
571
572 for (const key of Object.keys(currentRecord)) {
573 if (ATOMIC_SETTINGS_KEYS.has(key)) {
574 if (!Equal.equals(currentRecord[key], defaultsRecord[key])) {
575 next[key] = currentRecord[key];
576 }
577 } else {
578 const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]);
579 if (stripped !== undefined) {
580 next[key] = stripped;
581 }
582 }
583 }
584
585 return Object.keys(next).length > 0 ? next : undefined;
586 }
587
588 return Object.is(current, defaults) ? undefined : current;
589}
590
591const decodeProjectScriptsJson = Schema.decodeUnknownOption(
592 Schema.fromJsonString(Schema.Array(ProjectScript)),
593);
594const decodeModelSelectionJson = Schema.decodeUnknownOption(
595 Schema.fromJsonString(Schema.NullOr(ModelSelection)),
596);
597
598interface LegacyProjectSettingsRow {
599 readonly projectId: string;
600 readonly defaultModelSelection: string | null;
601 readonly defaultThreadEnvMode: string | null;
602 readonly autoPull: number;
603 readonly scripts: string;
604}
605
606// projectSettingsFolded prevents restarts from undoing later UI resets.
607function foldLegacyProjectSettings(
608 settings: ServerSettings,
609 rows: ReadonlyArray<LegacyProjectSettingsRow>,
610): ServerSettings {
611 if (settings.projectSettingsFolded) return settings;
612 if (
613 rows.length === 0 &&
614 Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 &&
615 Object.keys(settings.projectAutoPullOverrides).length === 0 &&
616 Object.keys(settings.projectScriptOverrides).length === 0
617 ) {
618 return settings;
619 }
620 const entries: Record<string, ProjectSettingsOverrides> = {
621 ...settings.projectSettingsOverrides,
622 };
623 const set = <K extends keyof ProjectSettingsOverrides>(
624 projectId: string,
625 key: K,
626 value: ProjectSettingsOverrides[K] | undefined,
627 ) => {
628 if (value === undefined) return;
629 const entry = entries[projectId] ?? {};
630 if (Object.hasOwn(entry, key)) return;
631 entries[projectId] = { ...entry, [key]: value };
632 };
633 for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) {
634 set(projectId, "enableAgentBrowserAccess", value);
635 }
636 for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) {
637 set(projectId, "defaultAutoPull", value);
638 }
639 // A stored null resets to machine defaults, so the project's aggregate scripts must not resurface.
640 const resetScripts = new Set<string>();
641 for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) {
642 if (value === null) resetScripts.add(projectId);
643 else set(projectId, "defaultProjectScripts", value);
644 }
645 for (const row of rows) {
646 const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null");
647 if (Option.isSome(model) && model.value !== null) {
648 set(row.projectId, "defaultModelSelection", model.value);
649 }
650 if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") {
651 set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode);
652 }
653 if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true);
654 const scripts = decodeProjectScriptsJson(row.scripts);
655 if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) {
656 set(row.projectId, "defaultProjectScripts", scripts.value);
657 }
658 }
659 const projectSettingsOverrides = Object.fromEntries(
660 Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0),
661 );
662 return {
663 ...settings,
664 projectSettingsOverrides,
665 projectSettingsFolded: true,
666 ...deriveLegacyProjectOverrides({ projectSettingsOverrides }),
667 };
668}
669
670const make = Effect.gen(function* () {
671 const { settingsPath } = yield* ServerConfig.ServerConfig;
672 const fs = yield* FileSystem.FileSystem;
673 const pathService = yield* Path.Path;
674 const secretStore = yield* ServerSecretStore.ServerSecretStore;
675 const sql = yield* SqlClient.SqlClient;
676 const {
677 settings: managed,
678 providers: managedProviders,
679 summary: managedPolicy,
680 } = yield* ManagedPolicy.ManagedPolicy;
681 // Locks are laid over every settings value that leaves this service, never persisted.
682 const toEffective = (settings: ServerSettings) =>
683 resolveTextGenerationProvider(
684 managedProviders.applyLimits(managed.applyLocks(settings)),
685 managedProviders.isDriverAllowed,
686 );
687 const writeSemaphore = yield* Semaphore.make(1);
688 const cacheKey = "settings" as const;
689 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
690 const startedRef = yield* Ref.make(false);
691 const startedDeferred = yield* Deferred.make<void, ServerSettingsError>();
692 const watcherScope = yield* Scope.make("sequential");
693 yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));
694
695 const emitChange = (settings: ServerSettings) =>
696 PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid);
697
698 const readConfigExists = fs.exists(settingsPath).pipe(
699 Effect.mapError(
700 (cause) =>
701 new ServerSettingsError({
702 settingsPath,
703 operation: "check-exists",
704 cause,
705 }),
706 ),
707 );
708
709 const readRawConfig = fs.readFileString(settingsPath).pipe(
710 Effect.mapError(
711 (cause) =>
712 new ServerSettingsError({
713 settingsPath,
714 operation: "read-file",
715 cause,
716 }),
717 ),
718 );
719
720 // The file before this write, parsed; only read when policy defaults need it.
721 const readStoredSettings = Effect.gen(function* () {
722 if (!(yield* readConfigExists)) return undefined;
723 const parsed = decodeSettingsFileJsonExit(yield* readRawConfig);
724 return Exit.isSuccess(parsed) ? parsed.value : undefined;
725 });
726
727 const writeSettingsAtomically = Effect.fnUntraced(
728 function* (settings: ServerSettings) {
729 const stripped = stripDefaultServerSettings(settings, managed.defaults) ?? {};
730 const sparseSettingsJson = yield* encodeServerSettingsJson(
731 managed.keepStored === undefined
732 ? stripped
733 : managed.keepStored(stripped, settings, yield* readStoredSettings, (key) =>
734 ATOMIC_SETTINGS_KEYS.has(key),
735 ),
736 );
737
738 return yield* writeFileStringAtomically({
739 filePath: settingsPath,
740 contents: `${sparseSettingsJson}\n`,
741 }).pipe(
742 Effect.provideService(FileSystem.FileSystem, fs),
743 Effect.provideService(Path.Path, pathService),
744 );
745 },
746 Effect.mapError(
747 (cause) =>
748 new ServerSettingsError({
749 settingsPath,
750 operation: "write-file",
751 cause,
752 }),
753 ),
754 );
755
756 const moveInlineSettingsSecrets = Effect.fn("serverSettings.moveInlineSettingsSecrets")(
757 function* (settings: ServerSettings) {
758 const pending: SettingsSecret[] = [];
759 mapSettingsSecrets(settings, (secret) => {
760 if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) {
761 pending.push(secret);
762 }
763 return secret;
764 });
765 const moved = new Set<string>();
766 for (const secret of pending) {
767 const stored = yield* secretStore
768 .set(secret.secretName, textEncoder.encode(secret.value))
769 .pipe(
770 Effect.as(true),
771 Effect.catch(() =>
772 Effect.logWarning("failed to move a settings secret into the secret store").pipe(
773 Effect.as(false),
774 ),
775 ),
776 );
777 if (stored) moved.add(secret.secretName);
778 }
779 if (moved.size === 0) return settings;
780 return mapSettingsSecrets(settings, (secret) =>
781 moved.has(secret.secretName) ? { value: "", redacted: true } : secret,
782 );
783 },
784 );
785
786 const loadSettingsFromDisk = Effect.gen(function* () {
787 let settings = managed.defaults;
788 let legacyProviders: Readonly<Record<string, unknown>> | undefined;
789 let legacySourceControl: Parameters<typeof migrateLegacySourceControlSettings>[1] = {};
790 let parseFailure = Option.none<unknown>();
791
792 if (yield* readConfigExists) {
793 const raw = yield* readRawConfig;
794 const parsed = decodeSettingsFileJsonExit(raw);
795 const decoded = Exit.isFailure(parsed)
796 ? parsed
797 : decodePersistedServerSettingsExit(managed.withDefaults(parsed.value));
798 const legacySettings = decodeLegacyProviderSettingsJsonExit(raw);
799 if (legacySettings._tag === "Success") {
800 legacyProviders = legacySettings.value.providers;
801 legacySourceControl = {
802 bitbucket: legacySettings.value.bitbucket,
803 github: legacySettings.value.github,
804 };
805 }
806 if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
807 const failure = decoded._tag === "Failure" ? decoded : legacySettings;
808 // The file may hold an opt-out we can no longer read; never resume threads on a guess.
809 settings = { ...managed.defaults, continueThreadsAfterServerUpdate: false };
810 if (failure._tag === "Failure") {
811 parseFailure = Option.some(Cause.squash(failure.cause));
812 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
813 path: settingsPath,
814 issues: Cause.pretty(failure.cause),
815 cause: failure.cause,
816 });
817 }
818 } else {
819 settings = decoded.value;
820 }
821 }
822 // Settings saved by a server that kept the mode in `compactIdleThreads` move
823 // it to `idleCompaction`, so that switch goes to clients as a boolean. The
824 // defaults gain the field too, which tells clients this server reads modes.
825 settings = withIdleCompaction(settings, globalIdleCompaction(settings));
826
827 // A file that failed to decode must stay on disk for the user to repair.
828 const settingsFileTrusted = Option.isNone(parseFailure);
829 const providerHistory = yield* sql<{
830 readonly providerName: string;
831 readonly providerInstanceId: string | null;
832 }>`
833 SELECT DISTINCT
834 provider_name AS "providerName",
835 provider_instance_id AS "providerInstanceId"
836 FROM projection_thread_sessions
837 WHERE provider_name IN ('cursor', 'grok', 'opencode')
838 UNION
839 SELECT DISTINCT
840 provider_name AS "providerName",
841 provider_instance_id AS "providerInstanceId"
842 FROM provider_session_runtime
843 WHERE provider_name IN ('cursor', 'grok', 'opencode')
844 `.pipe(
845 NodeSqliteClient.readOnly,
846 Effect.mapError(
847 (cause) =>
848 new ServerSettingsError({
849 settingsPath,
850 operation: "read-provider-history",
851 cause,
852 }),
853 ),
854 );
855
856 const legacyProjectRows =
857 settings.projectSettingsFolded || !settingsFileTrusted
858 ? []
859 : yield* sql<LegacyProjectSettingsRow>`
860 SELECT
861 project_id AS "projectId",
862 default_model_selection_json AS "defaultModelSelection",
863 default_thread_env_mode AS "defaultThreadEnvMode",
864 auto_pull AS "autoPull",
865 scripts_json AS "scripts"
866 FROM projection_projects
867 WHERE deleted_at IS NULL
868 `.pipe(
869 NodeSqliteClient.readOnly,
870 Effect.mapError(
871 (cause) =>
872 new ServerSettingsError({
873 settingsPath,
874 operation: "read-project-settings",
875 cause,
876 }),
877 ),
878 );
879
880 const loaded = foldProviderInstanceEnabledFlags(
881 legacyProviders === undefined
882 ? settings
883 : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory),
884 );
885 const folded = settingsFileTrusted
886 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
887 : loaded;
888 // Remove retired keys only after copying secrets and writing the new file; failures retry on next load.
889 const legacySecretsCopied = yield* Effect.forEach(
890 settingsFileTrusted
891 ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves
892 : [],
893 (move) =>
894 secretStore.get(move.from).pipe(
895 Effect.flatMap((secret) =>
896 Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value),
897 ),
898 Effect.as(true),
899 Effect.catch(() =>
900 Effect.logWarning("failed to copy a source control secret to its new name", {
901 secretName: move.to,
902 }).pipe(Effect.as(false)),
903 ),
904 ),
905 ).pipe(Effect.map((copied) => copied.every(Boolean)));
906 const hasLegacySourceControl =
907 settingsFileTrusted &&
908 legacySecretsCopied &&
909 (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined);
910 const sourceControlMigration = hasLegacySourceControl
911 ? migrateLegacySourceControlSettings(folded, legacySourceControl)
912 : { settings: folded, secretMoves: [] };
913 const migrated = settingsFileTrusted
914 ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings)
915 : folded;
916 if (
917 migrated !== loaded ||
918 (settingsFileTrusted && legacyProviders !== undefined) ||
919 hasLegacySourceControl
920 ) {
921 yield* writeSettingsAtomically(migrated);
922 for (const move of sourceControlMigration.secretMoves) {
923 yield* secretStore.remove(move.from).pipe(Effect.ignore);
924 }
925 }
926 return { settings: migrated, parseFailure };
927 });
928
929 const settingsCache = yield* Cache.makeWith((_key: typeof cacheKey) => loadSettingsFromDisk, {
930 capacity: 1,
931 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
932 });
933
934 const getSettingsFromCache = Cache.get(settingsCache, cacheKey).pipe(
935 Effect.map((loaded) => loaded.settings),
936 );
937 const reloadSettings = Cache.invalidate(settingsCache, cacheKey).pipe(
938 Effect.andThen(Cache.get(settingsCache, cacheKey)),
939 );
940
941 const materializeSettingsSecrets = Effect.fn("serverSettings.materializeSettingsSecrets")(
942 function* (settings: ServerSettings): Effect.fn.Return<ServerSettings, ServerSettingsError> {
943 const storedSecrets = new Map<string, string>();
944 const pending = new Map<string, SettingsSecret>();
945 mapSettingsSecrets(settings, (secret) => {
946 if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret);
947 return secret;
948 });
949 for (const secret of pending.values()) {
950 const value = yield* secretStore.get(secret.secretName).pipe(
951 Effect.mapError(
952 (cause) =>
953 new ServerSettingsError({
954 settingsPath,
955 operation: "read-secret",
956 providerInstanceId: secret.providerInstanceId,
957 environmentVariable: secret.environmentVariable,
958 cause,
959 }),
960 ),
961 );
962 storedSecrets.set(
963 secret.secretName,
964 Option.isSome(value) ? textDecoder.decode(value.value) : "",
965 );
966 }
967 return mapSettingsSecrets(settings, (secret) => ({
968 value:
969 secret.sensitive && secret.redacted
970 ? (storedSecrets.get(secret.secretName) ?? "")
971 : secret.value,
972 redacted: secret.sensitive && secret.kind === "environment" && secret.redacted,
973 }));
974 },
975 );
976
977 // Background sweeps read settings every few seconds; reading secret files each time is wasted.
978 // Every secret write rewrites the settings file, so a new cached object implies fresh secrets.
979 // A read that overlaps a secret write may see files a rollback then restores, so it is not kept.
980 let materialized: { readonly source: ServerSettings; readonly value: ServerSettings } | null =
981 null;
982 let secretWriteGeneration = 0;
983 const invalidateMaterialized = Effect.sync(() => {
984 secretWriteGeneration++;
985 materialized = null;
986 });
987 const getMaterializedSettings = getSettingsFromCache.pipe(
988 Effect.flatMap((source) => {
989 if (materialized?.source === source) return Effect.succeed(materialized.value);
990 const generation = secretWriteGeneration;
991 return materializeSettingsSecrets(source).pipe(
992 Effect.map(toEffective),
993 Effect.tap((value) =>
994 Effect.sync(() => {
995 if (generation === secretWriteGeneration) materialized = { source, value };
996 }),
997 ),
998 );
999 }),
1000 );
1001
1002 const materializeChanges = (changes: Stream.Stream<ServerSettings>) =>
1003 changes.pipe(
1004 Stream.mapEffect((settings) =>
1005 materializeSettingsSecrets(settings).pipe(
1006 Effect.catch((error: ServerSettingsError) =>
1007 Effect.logWarning("failed to materialize settings secrets", {
1008 operation: error.operation,
1009 providerInstanceId: error.providerInstanceId,
1010 environmentVariable: error.environmentVariable,
1011 cause: error.cause,
1012 }).pipe(Effect.as(settings)),
1013 ),
1014 ),
1015 ),
1016 Stream.map(toEffective),
1017 );
1018
1019 type SecretChange = {
1020 readonly secretName: string;
1021 readonly providerInstanceId?: string;
1022 readonly environmentVariable?: string;
1023 } & (
1024 | { readonly kind: "write"; readonly value: Uint8Array }
1025 | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" }
1026 );
1027
1028 const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) =>
1029 Effect.sync(() => {
1030 // Only sensitive rows own a stored secret; a plain duplicate must not hide one from cleanup.
1031 const previous = new Map<string, SettingsSecret>();
1032 const inlineValues = new Map<string, string>();
1033 mapSettingsSecrets(current, (secret) => {
1034 if (!secret.sensitive) return secret;
1035 previous.set(secret.secretName, secret);
1036 if (!secret.redacted && secret.value.length > 0) {
1037 inlineValues.set(secret.secretName, secret.value);
1038 }
1039 return secret;
1040 });
1041 const changes: SecretChange[] = [];
1042 const nextSecretNames = new Set<string>();
1043 const keptSecretNames = new Set<string>();
1044 const writtenSecretNames = new Set<string>();
1045 const inlineWrites = new Map<string, SecretChange>();
1046 const settings = mapSettingsSecrets(withEchoedSecretReferences(current, next), (secret) => {
1047 const metadata = {
1048 secretName: secret.secretName,
1049 ...(secret.providerInstanceId !== undefined
1050 ? { providerInstanceId: secret.providerInstanceId }
1051 : {}),
1052 ...(secret.environmentVariable !== undefined
1053 ? { environmentVariable: secret.environmentVariable }
1054 : {}),
1055 };
1056 if (!secret.sensitive) {
1057 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
1058 return { value: secret.value, redacted: false };
1059 }
1060 if (secret.redacted) {
1061 keptSecretNames.add(secret.secretName);
1062 nextSecretNames.add(secret.secretName);
1063 const inlineValue = inlineValues.get(secret.secretName);
1064 if (inlineValue !== undefined) {
1065 inlineWrites.set(secret.secretName, {
1066 ...metadata,
1067 kind: "write",
1068 value: textEncoder.encode(inlineValue),
1069 });
1070 }
1071 return { value: "", redacted: true };
1072 }
1073 if (secret.value.length === 0) {
1074 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
1075 return { value: "", redacted: false };
1076 }
1077 nextSecretNames.add(secret.secretName);
1078 writtenSecretNames.add(secret.secretName);
1079 changes.push({ ...metadata, kind: "write", value: textEncoder.encode(secret.value) });
1080 return { value: "", redacted: true };
1081 });
1082 // A legacy inline value only moves to the store when no row in this save replaces it.
1083 for (const [secretName, change] of inlineWrites) {
1084 if (!writtenSecretNames.has(secretName)) changes.push(change);
1085 }
1086 for (const secret of previous.values()) {
1087 if (nextSecretNames.has(secret.secretName)) continue;
1088 if (changes.some((change) => change.secretName === secret.secretName)) continue;
1089 changes.push({
1090 kind: "remove",
1091 secretName: secret.secretName,
1092 operation: "remove-stale-secret",
1093 ...(secret.providerInstanceId !== undefined
1094 ? { providerInstanceId: secret.providerInstanceId }
1095 : {}),
1096 ...(secret.environmentVariable !== undefined
1097 ? { environmentVariable: secret.environmentVariable }
1098 : {}),
1099 });
1100 }
1101 return {
1102 settings,
1103 // A redacted row still claims its stored secret, so a duplicate row must not remove it.
1104 changes: changes.filter(
1105 (change) => change.kind === "write" || !keptSecretNames.has(change.secretName),
1106 ),
1107 };
1108 });
1109
1110 const rollbackSettingsSecretWrites = (
1111 writes: ReadonlyArray<{
1112 readonly secretName: string;
1113 readonly previousValue: Option.Option<Uint8Array>;
1114 readonly providerInstanceId?: string;
1115 readonly environmentVariable?: string;
1116 }>,
1117 ) =>
1118 Effect.forEach(
1119 writes.toReversed(),
1120 (write) =>
1121 (Option.isSome(write.previousValue)
1122 ? secretStore.set(write.secretName, write.previousValue.value)
1123 : secretStore.remove(write.secretName)
1124 ).pipe(
1125 Effect.catch((cause) =>
1126 Effect.logWarning("failed to roll back settings secret", {
1127 providerInstanceId: write.providerInstanceId,
1128 environmentVariable: write.environmentVariable,
1129 cause,
1130 }),
1131 ),
1132 ),
1133 { discard: true },
1134 );
1135
1136 const applySettingsSecretChanges = (changes: ReadonlyArray<SecretChange>) => {
1137 const applied: Array<{
1138 readonly secretName: string;
1139 readonly previousValue: Option.Option<Uint8Array>;
1140 readonly providerInstanceId?: string;
1141 readonly environmentVariable?: string;
1142 }> = [];
1143 const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied));
1144 return Effect.forEach(
1145 changes,
1146 (change) =>
1147 Effect.gen(function* () {
1148 const previousValue = yield* secretStore.get(change.secretName).pipe(
1149 Effect.mapError(
1150 (cause) =>
1151 new ServerSettingsError({
1152 settingsPath,
1153 operation: "read-secret",
1154 providerInstanceId: change.providerInstanceId,
1155 environmentVariable: change.environmentVariable,
1156 cause,
1157 }),
1158 ),
1159 );
1160 // A store operation may mutate before reporting an error (for example chmod after rename).
1161 applied.push({ ...change, previousValue });
1162 yield* (
1163 change.kind === "write"
1164 ? secretStore.set(change.secretName, change.value)
1165 : secretStore.remove(change.secretName)
1166 ).pipe(
1167 Effect.mapError(
1168 (cause) =>
1169 new ServerSettingsError({
1170 settingsPath,
1171 operation: change.kind === "write" ? "write-secret" : change.operation,
1172 providerInstanceId: change.providerInstanceId,
1173 environmentVariable: change.environmentVariable,
1174 cause,
1175 }),
1176 ),
1177 );
1178 }),
1179 { discard: true },
1180 ).pipe(
1181 Effect.tapError(() => rollback),
1182 Effect.as(rollback),
1183 );
1184 };
1185
1186 const updateAndPersistSettings = (
1187 patch: ServerSettingsPatch,
1188 update: (current: ServerSettings) => Effect.Effect<ServerSettings, ServerSettingsError>,
1189 ): Effect.Effect<ServerSettings, ServerSettingsError> =>
1190 writeSemaphore.withPermits(1)(
1191 Effect.gen(function* () {
1192 const { settings: current, parseFailure } = yield* reloadSettings;
1193 if (Option.isSome(parseFailure)) {
1194 return yield* new ServerSettingsError({
1195 settingsPath,
1196 operation: "read-file",
1197 cause: parseFailure.value,
1198 });
1199 }
1200 // An older client echoes a reference as the redacted marker; restore it before vetting so
1201 // a removed provider's reference is cleared below rather than restored by persistence.
1202 const updated = withEchoedSecretReferences(current, yield* update(current));
1203 // Vet before secrets: a variable the policy hides from clients is not one they removed.
1204 const vetted = managed.vetWrite(current, yield* normalizeServerSettings(updated), patch);
1205 if (vetted._tag === "Locked") {
1206 return yield* new ServerSettingsError({
1207 settingsPath,
1208 operation: "write-managed-setting",
1209 managedSetting: vetted.path,
1210 managedBy: managedPolicy?.organization,
1211 });
1212 }
1213 const vettedProviders = managedProviders.vetWrite(current, vetted.settings);
1214 if (vettedProviders._tag === "Locked") {
1215 return yield* new ServerSettingsError({
1216 settingsPath,
1217 operation: "write-managed-setting",
1218 managedSetting: vettedProviders.path,
1219 managedBy: managedPolicy?.organization,
1220 });
1221 }
1222 if (vettedProviders._tag === "Disallowed") {
1223 return yield* new ServerSettingsError({
1224 settingsPath,
1225 operation: "use-disallowed-provider",
1226 providerInstanceId: vettedProviders.instanceId,
1227 providerLabel: vettedProviders.label,
1228 managedBy: managedPolicy?.organization,
1229 });
1230 }
1231 // After vetting, which can keep stored variables a policy hides from clients.
1232 const persisted = yield* persistSettingsSecrets(
1233 current,
1234 withoutRemovedSecretProviderReferences(current, vettedProviders.settings),
1235 );
1236 const next = yield* normalizeServerSettings(persisted.settings);
1237 const materializedNext = yield* Effect.uninterruptibleMask(() =>
1238 Effect.gen(function* () {
1239 yield* invalidateMaterialized;
1240 const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes);
1241 const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next));
1242 if (Exit.isFailure(materializedExit)) {
1243 yield* rollbackSecretChanges;
1244 return yield* Effect.failCause(materializedExit.cause);
1245 }
1246 const writeExit = yield* Effect.exit(writeSettingsAtomically(next));
1247 if (Exit.isFailure(writeExit)) {
1248 yield* rollbackSecretChanges;
1249 return yield* Effect.failCause(writeExit.cause);
1250 }
1251 return materializedExit.value;
1252 }).pipe(Effect.ensuring(invalidateMaterialized)),
1253 );
1254 yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() });
1255 const resolved = toEffective(materializedNext);
1256 materialized = { source: next, value: resolved };
1257 yield* emitChange(next);
1258 return resolved;
1259 }),
1260 );
1261
1262 const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) =>
1263 writeSemaphore.withPermits(1)(getMaterializedSettings.pipe(Effect.flatMap(use)));
1264
1265 const revalidateAndEmit = writeSemaphore.withPermits(1)(
1266 Effect.gen(function* () {
1267 const { settings } = yield* reloadSettings;
1268 yield* emitChange(settings);
1269 }),
1270 );
1271
1272 const watchFileChanges = (filePath: string) => {
1273 const directory = pathService.dirname(filePath);
1274 const fileName = pathService.basename(filePath);
1275 const resolvedFilePath = pathService.resolve(filePath);
1276 return fs
1277 .watch(directory)
1278 .pipe(
1279 Stream.filter(
1280 (event) =>
1281 event.path === fileName ||
1282 event.path === filePath ||
1283 pathService.resolve(directory, event.path) === resolvedFilePath,
1284 ),
1285 );
1286 };
1287
1288 const startWatcher = Effect.gen(function* () {
1289 const settingsDir = pathService.dirname(settingsPath);
1290
1291 yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe(
1292 Effect.mapError(
1293 (cause) =>
1294 new ServerSettingsError({
1295 settingsPath,
1296 operation: "prepare-directory",
1297 cause,
1298 }),
1299 ),
1300 );
1301
1302 const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));
1303
1304 // Watch symlink targets' directories for rewrites; re-resolve link changes to follow repointing.
1305 const watchLinkTarget = Effect.gen(function* () {
1306 const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe(
1307 Effect.provideService(FileSystem.FileSystem, fs),
1308 Effect.provideService(Path.Path, pathService),
1309 );
1310 if (linkTargetPath === pathService.resolve(settingsPath)) {
1311 return Option.none<string>();
1312 }
1313 yield* fs
1314 .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true })
1315 .pipe(Effect.ignore({ log: true }));
1316 return Option.some(linkTargetPath);
1317 }).pipe(Effect.orElseSucceed(() => Option.none<string>()));
1318
1319 const initialLinkTarget = yield* watchLinkTarget;
1320 const linkTargetEvents = Stream.make(initialLinkTarget).pipe(
1321 Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))),
1322 Stream.changes,
1323 Stream.switchMap(
1324 Option.match({
1325 onNone: () => Stream.empty,
1326 onSome: (linkTargetPath) =>
1327 watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })),
1328 }),
1329 ),
1330 );
1331
1332 // Saves emit multiple events; fs.watch may fire before content is flushed.
1333 const debouncedSettingsEvents = Stream.merge(
1334 watchFileChanges(settingsPath),
1335 linkTargetEvents,
1336 ).pipe(Stream.debounce(Duration.millis(100)));
1337
1338 yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe(
1339 Effect.ignoreCause({ log: true }),
1340 Effect.forkIn(watcherScope),
1341 Effect.asVoid,
1342 );
1343 });
1344
1345 const start = Effect.gen(function* () {
1346 const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]);
1347 if (!shouldStart) {
1348 return yield* Deferred.await(startedDeferred);
1349 }
1350
1351 const startup = Effect.gen(function* () {
1352 yield* startWatcher;
1353 yield* reloadSettings;
1354 });
1355
1356 const startupExit = yield* Effect.exit(startup);
1357 if (startupExit._tag === "Failure") {
1358 yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie);
1359 return yield* Effect.failCause(startupExit.cause);
1360 }
1361
1362 yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie);
1363 });
1364
1365 return {
1366 managedPolicy,
1367 start,
1368 ready: Deferred.await(startedDeferred),
1369 getSettings: getMaterializedSettings,
1370 updateSettings: (patch) =>
1371 updateAndPersistSettings(patch, (current) =>
1372 ensureSecretProviderWritesAllowed(patch, settingsPath).pipe(
1373 Effect.as(applyServerSettingsPatch(current, patch)),
1374 ),
1375 ),
1376 updateProviderInstance: (mutation, patch = {}) =>
1377 updateAndPersistSettings(patch, (current) =>
1378 Effect.gen(function* () {
1379 yield* ensureSecretProviderWritesAllowed(patch, settingsPath);
1380 yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath);
1381 const patched = applyServerSettingsPatch(current, patch);
1382 return applyProviderInstanceMutation(patched, mutation);
1383 }),
1384 ),
1385 withSettingsSnapshot,
1386 get streamChanges() {
1387 return materializeChanges(Stream.fromPubSub(changesPubSub));
1388 },
1389 get subscribeChanges() {
1390 return PubSub.subscribe(changesPubSub).pipe(
1391 Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))),
1392 );
1393 },
1394 } satisfies ServerSettingsService["Service"];
1395});
1396
1397export const layer = Layer.effect(ServerSettingsService, make);