/** * ServerSettings - Server-authoritative settings service. * * Owns persistence, validation, and change notification of settings that affect * server-side behavior (binary paths, streaming mode, env mode, custom models, * text generation model selection). * * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + * Semaphore + FileSystem.watch for concurrency and external edit detection. * * @module ServerSettings */import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, type ModelSelection, type ProviderKind, ServerSettings, ServerSettingsError, type ServerSettingsPatch,} from "@t3tools/contracts";import { Cache, Deferred, Duration, Effect, Exit, FileSystem, Layer, Path, Equal, PubSub, Ref, Schema, SchemaIssue, Scope, Context, Stream, Cause,} from "effect";import * as Semaphore from "effect/Semaphore";import { writeFileStringAtomically } from "./atomicWrite.ts";import { ServerConfig } from "./config.ts";import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";import { fromLenientJson } from "@t3tools/shared/schemaJson";import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ readonly start: Effect.Effect<void, ServerSettingsError>; /** Await settings runtime readiness. */ readonly ready: Effect.Effect<void, ServerSettingsError>; /** Read the current settings. */ readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>; /** Patch settings and persist. Returns the new full settings object. */ readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect<ServerSettings, ServerSettingsError>; /** Stream of settings change events. */ readonly streamChanges: Stream.Stream<ServerSettings>;} export class ServerSettingsService extends Context.Service< ServerSettingsService, ServerSettingsShape>()("t3/serverSettings/ServerSettingsService") { static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) => Layer.effect( ServerSettingsService, Effect.gen(function* () { const currentSettingsRef = yield* Ref.make<ServerSettings>( deepMerge(DEFAULT_SERVER_SETTINGS, overrides), ); return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( Effect.flatMap((currentSettings) => Schema.decodeEffect(ServerSettings)( applyServerSettingsPatch(currentSettings, patch), ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath: "<memory>", detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, cause, }), ), ), ), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, } satisfies ServerSettingsShape; }), );} const ServerSettingsJson = fromLenientJson(ServerSettings); const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. * If the selected provider is disabled, fall back to the first enabled * provider with its default model. This is applied at read-time so the * persisted preference is preserved for when a provider is re-enabled. */function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; if (settings.providers[selection.provider].enabled) { return settings; } const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); if (!fallback) { // No providers enabled — return as-is; callers will report the error. return settings; } return { ...settings, textGenerationModelSelection: { provider: fallback, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback], } as ModelSelection, };} // Values under these keys are compared as a whole — never stripped field-by-field.const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]); function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; } if ( current !== null && defaults !== null && typeof current === "object" && typeof defaults === "object" ) { const currentRecord = current as Record<string, unknown>; const defaultsRecord = defaults as Record<string, unknown>; const next: Record<string, unknown> = {}; for (const key of Object.keys(currentRecord)) { if (ATOMIC_SETTINGS_KEYS.has(key)) { if (!Equal.equals(currentRecord[key], defaultsRecord[key])) { next[key] = currentRecord[key]; } } else { const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]); if (stripped !== undefined) { next[key] = stripped; } } } return Object.keys(next).length > 0 ? next : undefined; } return Object.is(current, defaults) ? undefined : current;} const makeServerSettings = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig; const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded<ServerSettings>(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make<void, ServerSettingsError>(); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); const emitChange = (settings: ServerSettings) => PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); const readConfigExists = fs.exists(settingsPath).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to check settings file existence", cause, }), ), ); const readRawConfig = fs.readFileString(settingsPath).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to read settings file", cause, }), ), ); const loadSettingsFromDisk = Effect.gen(function* () { if (!(yield* readConfigExists)) { return DEFAULT_SERVER_SETTINGS; } const raw = yield* readRawConfig; const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); if (decoded._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(decoded.cause), }); return DEFAULT_SERVER_SETTINGS; } return decoded.value; }); const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({ capacity: 1, lookup: () => loadSettingsFromDisk, }); const getSettingsFromCache = Cache.get(settingsCache, cacheKey); const writeSettingsAtomically = (settings: ServerSettings) => { const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; return writeFileStringAtomically({ filePath: settingsPath, contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathService), Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to write settings file", cause, }), ), ); }; const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); const settings = yield* getSettingsFromCache; yield* emitChange(settings); }), ); const startWatcher = Effect.gen(function* () { const settingsDir = pathService.dirname(settingsPath); const settingsFile = pathService.basename(settingsPath); const settingsPathResolved = pathService.resolve(settingsPath); yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, detail: "failed to prepare settings directory", cause, }), ), ); const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and // `fs.watch` can fire before the content has been flushed to disk. const debouncedSettingsEvents = fs.watch(settingsDir).pipe( Stream.filter((event) => { return ( event.path === settingsFile || event.path === settingsPath || pathService.resolve(settingsDir, event.path) === settingsPathResolved ); }), Stream.debounce(Duration.millis(100)), ); yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid, ); }); const start = Effect.gen(function* () { const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]); if (!shouldStart) { return yield* Deferred.await(startedDeferred); } const startup = Effect.gen(function* () { yield* startWatcher; yield* Cache.invalidate(settingsCache, cacheKey); yield* getSettingsFromCache; }); const startupExit = yield* Effect.exit(startup); if (startupExit._tag === "Failure") { yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); return yield* Effect.failCause(startupExit.cause); } yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); return { start, ready: Deferred.await(startedDeferred), getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)), updateSettings: (patch) => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; const next = yield* Schema.decodeEffect(ServerSettings)( applyServerSettingsPatch(current, patch), ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath: "<memory>", detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, cause, }), ), ); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); yield* emitChange(next); return resolveTextGenerationProvider(next); }), ), get streamChanges() { return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); }, } satisfies ServerSettingsShape;}); export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings);
/**
* ServerSettings - Server-authoritative settings service.
*
* Owns persistence, validation, and change notification of settings that affect
* server-side behavior (binary paths, streaming mode, env mode, custom models,
* text generation model selection).
*
* Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub +
* Semaphore + FileSystem.watch for concurrency and external edit detection.
*
* @module ServerSettings
*/
import {
DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
DEFAULT_SERVER_SETTINGS,
type ModelSelection,
type ProviderKind,
ServerSettings,
ServerSettingsError,
type ServerSettingsPatch,
} from "@t3tools/contracts";
import {
Cache,
Deferred,
Duration,
Effect,
Exit,
FileSystem,
Layer,
Path,
Equal,
PubSub,
Ref,
Schema,
SchemaIssue,
Scope,
Context,
Stream,
Cause,
} from "effect";
import * as Semaphore from "effect/Semaphore";
import { writeFileStringAtomically } from "./atomicWrite.ts";
import { ServerConfig } from "./config.ts";
import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
export interface ServerSettingsShape {
/** Start the settings runtime and attach file watching. */
readonly start: Effect.Effect<void, ServerSettingsError>;
/** Await settings runtime readiness. */
readonly ready: Effect.Effect<void, ServerSettingsError>;
/** Read the current settings. */
readonly getSettings: Effect.Effect<ServerSettings, ServerSettingsError>;
/** Patch settings and persist. Returns the new full settings object. */
readonly updateSettings: (
patch: ServerSettingsPatch,
) => Effect.Effect<ServerSettings, ServerSettingsError>;
/** Stream of settings change events. */
readonly streamChanges: Stream.Stream<ServerSettings>;
}
export class ServerSettingsService extends Context.Service<
ServerSettingsService,
ServerSettingsShape
>()("t3/serverSettings/ServerSettingsService") {
static readonly layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>
Layer.effect(
ServerSettingsService,
Effect.gen(function* () {
const currentSettingsRef = yield* Ref.make<ServerSettings>(
deepMerge(DEFAULT_SERVER_SETTINGS, overrides),
);
return {
start: Effect.void,
ready: Effect.void,
getSettings: Ref.get(currentSettingsRef),
updateSettings: (patch) =>
Ref.get(currentSettingsRef).pipe(
Effect.flatMap((currentSettings) =>
Schema.decodeEffect(ServerSettings)(
applyServerSettingsPatch(currentSettings, patch),
).pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath: "<memory>",
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
cause,
}),
),
),
),
Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
),
streamChanges: Stream.empty,
} satisfies ServerSettingsShape;
}),
);
}
const ServerSettingsJson = fromLenientJson(ServerSettings);
const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"];
/**
* Ensure the `textGenerationModelSelection` points to an enabled provider.
* If the selected provider is disabled, fall back to the first enabled
* provider with its default model. This is applied at read-time so the
* persisted preference is preserved for when a provider is re-enabled.
*/
function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {
const selection = settings.textGenerationModelSelection;
if (settings.providers[selection.provider].enabled) {
return settings;
}
const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled);
if (!fallback) {
// No providers enabled — return as-is; callers will report the error.
return settings;
}
return {
...settings,
textGenerationModelSelection: {
provider: fallback,
model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback],
} as ModelSelection,
};
}
// Values under these keys are compared as a whole — never stripped field-by-field.
const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]);
function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
if (Array.isArray(current) || Array.isArray(defaults)) {
return Equal.equals(current, defaults) ? undefined : current;
}
if (
current !== null &&
defaults !== null &&
typeof current === "object" &&
typeof defaults === "object"
) {
const currentRecord = current as Record<string, unknown>;
const defaultsRecord = defaults as Record<string, unknown>;
const next: Record<string, unknown> = {};
for (const key of Object.keys(currentRecord)) {
if (ATOMIC_SETTINGS_KEYS.has(key)) {
if (!Equal.equals(currentRecord[key], defaultsRecord[key])) {
next[key] = currentRecord[key];
}
} else {
const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]);
if (stripped !== undefined) {
next[key] = stripped;
}
}
}
return Object.keys(next).length > 0 ? next : undefined;
}
return Object.is(current, defaults) ? undefined : current;
}
const makeServerSettings = Effect.gen(function* () {
const { settingsPath } = yield* ServerConfig;
const fs = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const writeSemaphore = yield* Semaphore.make(1);
const cacheKey = "settings" as const;
const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
const startedRef = yield* Ref.make(false);
const startedDeferred = yield* Deferred.make<void, ServerSettingsError>();
const watcherScope = yield* Scope.make("sequential");
yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));
const emitChange = (settings: ServerSettings) =>
PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid);
const readConfigExists = fs.exists(settingsPath).pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath,
detail: "failed to check settings file existence",
cause,
}),
),
);
const readRawConfig = fs.readFileString(settingsPath).pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath,
detail: "failed to read settings file",
cause,
}),
),
);
const loadSettingsFromDisk = Effect.gen(function* () {
if (!(yield* readConfigExists)) {
return DEFAULT_SERVER_SETTINGS;
}
const raw = yield* readRawConfig;
const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw);
if (decoded._tag === "Failure") {
yield* Effect.logWarning("failed to parse settings.json, using defaults", {
path: settingsPath,
issues: Cause.pretty(decoded.cause),
});
return DEFAULT_SERVER_SETTINGS;
}
return decoded.value;
});
const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
capacity: 1,
lookup: () => loadSettingsFromDisk,
});
const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
const writeSettingsAtomically = (settings: ServerSettings) => {
const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
return writeFileStringAtomically({
filePath: settingsPath,
contents: `${JSON.stringify(sparseSettings, null, 2)}\n`,
}).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, pathService),
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath,
detail: "failed to write settings file",
cause,
}),
),
);
};
const revalidateAndEmit = writeSemaphore.withPermits(1)(
Effect.gen(function* () {
yield* Cache.invalidate(settingsCache, cacheKey);
const settings = yield* getSettingsFromCache;
yield* emitChange(settings);
}),
);
const startWatcher = Effect.gen(function* () {
const settingsDir = pathService.dirname(settingsPath);
const settingsFile = pathService.basename(settingsPath);
const settingsPathResolved = pathService.resolve(settingsPath);
yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath,
detail: "failed to prepare settings directory",
cause,
}),
),
);
const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));
// Debounce watch events so the file is fully written before we read it.
// Editors emit multiple events per save (truncate, write, rename) and
// `fs.watch` can fire before the content has been flushed to disk.
const debouncedSettingsEvents = fs.watch(settingsDir).pipe(
Stream.filter((event) => {
return (
event.path === settingsFile ||
event.path === settingsPath ||
pathService.resolve(settingsDir, event.path) === settingsPathResolved
);
}),
Stream.debounce(Duration.millis(100)),
);
yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe(
Effect.ignoreCause({ log: true }),
Effect.forkIn(watcherScope),
Effect.asVoid,
);
});
const start = Effect.gen(function* () {
const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]);
if (!shouldStart) {
return yield* Deferred.await(startedDeferred);
}
const startup = Effect.gen(function* () {
yield* startWatcher;
yield* Cache.invalidate(settingsCache, cacheKey);
yield* getSettingsFromCache;
});
const startupExit = yield* Effect.exit(startup);
if (startupExit._tag === "Failure") {
yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie);
return yield* Effect.failCause(startupExit.cause);
}
yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie);
});
return {
start,
ready: Deferred.await(startedDeferred),
getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)),
updateSettings: (patch) =>
writeSemaphore.withPermits(1)(
Effect.gen(function* () {
const current = yield* getSettingsFromCache;
const next = yield* Schema.decodeEffect(ServerSettings)(
applyServerSettingsPatch(current, patch),
).pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath: "<memory>",
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
cause,
}),
),
);
yield* writeSettingsAtomically(next);
yield* Cache.set(settingsCache, cacheKey, next);
yield* emitChange(next);
return resolveTextGenerationProvider(next);
}),
),
get streamChanges() {
return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider));
},
} satisfies ServerSettingsShape;
});
export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings);