Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,46 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("keeps an external settings edit that the watcher has not reloaded yet", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* serverSettings.getSettings;

yield* writeFileStringAtomically({
filePath: config.settingsPath,
contents: `{ "responseStreamingMode": "turn" }`,
});
yield* serverSettings.updateSettings({
observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" },
});

const saved = yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath));
assert.equal(saved.responseStreamingMode, "turn");
assert.equal(saved.observability.otlpTracesUrl, "http://localhost:4318/v1/traces");
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("refuses to save over a settings file it cannot parse", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
const raw = `{ "responseStreamingMode": "turn", `;
yield* fs.writeFileString(config.settingsPath, raw);

const error = yield* Effect.flip(
serverSettings.updateSettings({
observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" },
}),
);

assert.equal(error.operation, "read-file");
assert.equal(yield* fs.readFileString(config.settingsPath), raw);
}).pipe(Effect.provide(layerServerSettings())),
);

it.effect("creates provider instances atomically without overwriting a concurrent add", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
Expand Down
43 changes: 28 additions & 15 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ const make = Effect.gen(function* () {
// A file that failed to decode must stay on disk for the user to repair;
// the fold below only writes when it started from the file's real contents.
let settingsFileTrusted = true;
let parseFailure = Option.none<unknown>();

if (yield* readConfigExists) {
const raw = yield* readRawConfig;
Expand All @@ -679,6 +680,7 @@ const make = Effect.gen(function* () {
// The file may hold an opt-out we can no longer read; never resume threads on a guess.
settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false };
if (failure._tag === "Failure") {
parseFailure = Option.some(Cause.squash(failure.cause));
yield* Effect.logWarning("failed to parse settings.json, using defaults", {
path: settingsPath,
issues: Cause.pretty(failure.cause),
Expand Down Expand Up @@ -787,18 +789,24 @@ const make = Effect.gen(function* () {
yield* secretStore.remove(move.from).pipe(Effect.ignore);
}
}
return migrated;
return { settings: migrated, parseFailure };
});

const settingsCache = yield* Cache.makeWith<typeof cacheKey, ServerSettings, ServerSettingsError>(
() => loadSettingsFromDisk,
{
capacity: 1,
timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
},
);
const settingsCache = yield* Cache.makeWith<
typeof cacheKey,
{ readonly settings: ServerSettings; readonly parseFailure: Option.Option<unknown> },
ServerSettingsError
>(() => loadSettingsFromDisk, {
capacity: 1,
timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
});

const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
const getSettingsFromCache = Cache.get(settingsCache, cacheKey).pipe(
Effect.map((loaded) => loaded.settings),
);
const reloadSettings = Cache.invalidate(settingsCache, cacheKey).pipe(
Effect.andThen(Cache.get(settingsCache, cacheKey)),
);

const materializeSettingsSecrets = (
settings: ServerSettings,
Expand Down Expand Up @@ -1001,7 +1009,14 @@ const make = Effect.gen(function* () {
): Effect.Effect<ServerSettings, ServerSettingsError> =>
writeSemaphore.withPermits(1)(
Effect.gen(function* () {
const current = yield* getSettingsFromCache;
const { settings: current, parseFailure } = yield* reloadSettings;
if (Option.isSome(parseFailure)) {
return yield* new ServerSettingsError({
settingsPath,
operation: "read-file",
cause: parseFailure.value,
});
}
const updated = yield* update(current);
const persisted = yield* persistSettingsSecrets(current, updated);
const next = yield* normalizeServerSettings(persisted.settings);
Expand All @@ -1021,7 +1036,7 @@ const make = Effect.gen(function* () {
return materializedExit.value;
}),
);
yield* Cache.set(settingsCache, cacheKey, next);
yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() });
yield* emitChange(next);
return resolveTextGenerationProvider(materialized);
}),
Expand All @@ -1038,8 +1053,7 @@ const make = Effect.gen(function* () {

const revalidateAndEmit = writeSemaphore.withPermits(1)(
Effect.gen(function* () {
yield* Cache.invalidate(settingsCache, cacheKey);
const settings = yield* getSettingsFromCache;
const { settings } = yield* reloadSettings;
yield* emitChange(settings);
}),
);
Expand Down Expand Up @@ -1128,8 +1142,7 @@ const make = Effect.gen(function* () {

const startup = Effect.gen(function* () {
yield* startWatcher;
yield* Cache.invalidate(settingsCache, cacheKey);
yield* getSettingsFromCache;
yield* reloadSettings;
});

const startupExit = yield* Effect.exit(startup);
Expand Down
Loading