diff --git a/CHANGELOG.md b/CHANGELOG.md index e443d6e308..bd9c5a9baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,11 @@ ### Fixed +- **payload-rules:** saved payload rules now survive a server restart. When no + in-memory override is set (fresh process before the boot hook ran, or a + separate module instance in the standalone build), `getPayloadRulesConfig` + now reads the DB-persisted rules (the source of truth) before the file config, + instead of silently returning the empty file default. (#2986) - **models/custom:** custom models can now carry a per-model `targetFormat` override (e.g. an opencode-go custom model that must use the Anthropic Messages shape). Previously custom models always routed as OpenAI-compatible because diff --git a/open-sse/services/payloadRules.ts b/open-sse/services/payloadRules.ts index 4422476b37..98ad426ee1 100644 --- a/open-sse/services/payloadRules.ts +++ b/open-sse/services/payloadRules.ts @@ -215,11 +215,36 @@ export function clearPayloadRulesConfigOverride() { runtimeOverride = null; } +// #2986: Read the DB-persisted payload rules (the source of truth, written by +// the Settings UI via updateSettings). Used as the fallback when no in-memory +// runtimeOverride is set — e.g. a fresh process before the startup +// applyRuntimeSettings hook ran, or a separate module instance in the +// standalone Next.js build — so saved rules survive a server restart instead of +// silently reverting to the (usually empty) file config. +async function loadPayloadRulesFromSettings(): Promise { + try { + const { getCachedSettings } = await import("@/lib/localDb"); + const settings = (await getCachedSettings()) as { payloadRules?: unknown }; + const raw = settings?.payloadRules; + if (raw === null || raw === undefined) return null; + return normalizePayloadRulesConfig(raw); + } catch { + return null; + } +} + export async function getPayloadRulesConfig(options: { forceRefresh?: boolean } = {}) { if (runtimeOverride) { return clonePayloadRulesConfig(runtimeOverride); } + // #2986: prefer the DB-persisted rules over the file config so a saved + // configuration survives a restart even when the in-memory override is absent. + const dbConfig = await loadPayloadRulesFromSettings(); + if (dbConfig) { + return clonePayloadRulesConfig(dbConfig); + } + await refreshPayloadRulesFileCache(options.forceRefresh === true); return clonePayloadRulesConfig(cachedFileConfig); } diff --git a/tests/unit/payload-rules-restart-persistence.test.ts b/tests/unit/payload-rules-restart-persistence.test.ts new file mode 100644 index 0000000000..e08bee0870 --- /dev/null +++ b/tests/unit/payload-rules-restart-persistence.test.ts @@ -0,0 +1,70 @@ +/** + * Issue #2986 — Payload Rules not persisting across server restart. + * + * Rules are written to the DB (key_value `settings.payloadRules`) and mirrored + * into an in-memory `runtimeOverride`. After a restart, if `runtimeOverride` is + * null (the boot hook didn't run in this module instance, or a separate bundle + * instance), `getPayloadRulesConfig` used to fall back to the (usually empty) + * file config and return no rules. + * + * Fix: when there is no in-memory override, read the DB-persisted rules — the + * source of truth — before the file. This test simulates a restart by clearing + * the in-memory override and asserting the persisted rules still come back. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-payload-rules-restart-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const payloadRulesService = await import("../../open-sse/services/payloadRules.ts"); + +test.after(() => { + payloadRulesService.resetPayloadRulesConfigForTests(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#2986 payload rules survive a restart (DB fallback when override is cleared)", async () => { + // Persist a rule to the DB (as the Settings UI does via updateSettings). + await settingsDb.updateSettings({ + payloadRules: { + default: [{ models: [{ name: "gpt-*" }], params: { temperature: 0.2 } }], + override: [], + filter: [], + }, + }); + + // Simulate a server restart: the in-memory override does not exist in a fresh + // process / module instance. + payloadRulesService.resetPayloadRulesConfigForTests(); + + const config = (await payloadRulesService.getPayloadRulesConfig({ forceRefresh: true })) as { + default?: unknown[]; + }; + assert.ok(Array.isArray(config.default), "config.default must be an array"); + assert.equal( + config.default!.length, + 1, + "the persisted rule must be returned after restart (read from the DB, not the empty file)" + ); +}); + +test("#2986 explicitly-empty persisted rules return an empty config (no over-broadening)", async () => { + // The user can save an empty configuration; the DB fallback must reflect that + // (not fabricate rules). updateSettings invalidates the settings cache. + await settingsDb.updateSettings({ + payloadRules: { default: [], override: [], filter: [] }, + }); + payloadRulesService.resetPayloadRulesConfigForTests(); + + const config = (await payloadRulesService.getPayloadRulesConfig({ forceRefresh: true })) as { + default?: unknown[]; + }; + assert.deepEqual(config.default, [], "empty DB rules → empty default (neutral config)"); +});