Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(compression):** the compression engines map now merges by engine id on `PUT /api/settings/compression` instead of storing the map whole, and the Omniglyph page and context settings panel send only the engine they changed — so a toggle on one page no longer wipes what the other page (or a failed settings GET) never loaded.
18 changes: 18 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2628,6 +2628,11 @@ paths:
put:
tags: [Compression]
summary: Update global compression settings
description: >-
Partial update: only the sent top-level keys change. `engines` merges by engine id —
an entry overwrites only the fields it sends, engines left out are kept as-is, and
until an engines row is stored the merge base is the map derived from the legacy
per-engine settings.
security:
- ManagementSessionAuth: []
requestBody:
Expand All @@ -2648,6 +2653,19 @@ paths:
autoTriggerTokens:
type: integer
minimum: 0
engines:
type: object
description: >-
Per-engine toggles, merged by engine id into the stored map. Each entry
needs `enabled`; `level` survives an entry that omits it.
additionalProperties:
type: object
properties:
enabled:
type: boolean
level:
type: string
required: [enabled]
rtkConfig:
type: object
additionalProperties: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//
// Card/Toggle are imported from their direct module paths (not the @/shared/components
// barrel) — the barrel pulls a Node-only module that hangs vitest/jsdom.
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Toggle from "@/shared/components/Toggle";
Expand All @@ -20,8 +20,6 @@ interface CompressionConfigLite {
omniglyph?: { profile?: string };
}

type EngineMap = Record<string, { enabled: boolean; level?: string }>;

/** Perfis do pacote, na ordem do mais permissivo ao mais restrito. O primeiro é
* o default: a política que os recibos publicados mediram. */
const PROFILES = [
Expand Down Expand Up @@ -243,83 +241,66 @@ function EnableCard(props: {
}

export default function OmniglyphContextPageClient() {
const [engines, setEngines] = useState<EngineMap>({});
const [enabled, setEnabled] = useState(false);
const [profile, setProfile] = useState<ProfileId>("aggressive");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<"" | "saved" | "error">("");
const saveGenRef = useRef(0);

useEffect(() => {
fetch("/api/settings/compression")
.then((r) => (r.ok ? r.json() : null))
.then((data: CompressionConfigLite | null) => {
const e = data?.engines ?? {};
setEngines(e);
setEnabled(e.omniglyph?.enabled === true);
setEnabled(data?.engines?.omniglyph?.enabled === true);
const stored = data?.omniglyph?.profile;
if (PROFILES.some((p) => p.id === stored)) setProfile(stored as ProfileId);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);

// Persist the FULL engines map (the store keeps it as one JSON row — a partial patch
// of a single engine would drop the others). Mirrors CompressionPanel.setEngine.
const toggle = async (next: boolean) => {
setEnabled(next);
const nextEngines: EngineMap = {
...engines,
omniglyph: { ...(engines.omniglyph ?? { enabled: false }), enabled: next },
};
setEngines(nextEngines);
// PUT one settings patch; a failed save runs `rollback`. The saved status clears after 2s
// unless a later save has started since, so it never clears that save's error.
const save = async (body: Record<string, unknown>, rollback: () => void) => {
const gen = ++saveGenRef.current;
setSaving(true);
setStatus("");
let ok = false;
try {
const res = await fetch("/api/settings/compression", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ engines: nextEngines }),
body: JSON.stringify(body),
});
if (res.ok) {
setStatus("saved");
setTimeout(() => setStatus(""), 2000);
} else {
setStatus("error");
}
ok = res.ok;
} catch {
// A network failure is a failed save.
}
setSaving(false);
if (!ok) {
rollback();
setStatus("error");
} finally {
setSaving(false);
return;
}
setStatus("saved");
setTimeout(() => {
if (gen === saveGenRef.current) setStatus("");
}, 2000);
};

// The server merges `engines` by engine id, so the toggle sends only the omniglyph entry.
const toggle = (next: boolean) => {
const previous = enabled;
setEnabled(next);
void save({ engines: { omniglyph: { enabled: next } } }, () => setEnabled(previous));
};

// O perfil vive na config do engine (não no mapa `engines`), então é um PATCH
// próprio — misturá-lo no payload do toggle reescreveria o mapa inteiro.
const changeProfile = async (next: ProfileId) => {
// O perfil vive na config do engine (não no mapa `engines`), então vai num PUT próprio.
const changeProfile = (next: ProfileId) => {
const previous = profile;
setProfile(next);
setSaving(true);
setStatus("");
try {
const res = await fetch("/api/settings/compression", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ omniglyph: { profile: next } }),
});
if (res.ok) {
setStatus("saved");
setTimeout(() => setStatus(""), 2000);
} else {
setProfile(previous);
setStatus("error");
}
} catch {
setProfile(previous);
setStatus("error");
} finally {
setSaving(false);
}
void save({ omniglyph: { profile: next } }, () => setProfile(previous));
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ export default function CompressionPanel() {
.catch(() => {});
}, []);

// Persist a merge-patch. The DB persists `engines` as one whole row, so callers that
// touch an engine pass the full engines map to avoid dropping the other engines.
// Persist a merge-patch. The server merges `engines` by engine id, so a caller that
// touches an engine passes only that engine, merged into the local map here.
// Generation + configRef: a later in-flight save must not let an older failure
// roll back a newer optimistic (or already-acked) state.
const save = async (updates: Partial<CompressionConfig>) => {
Expand All @@ -276,6 +276,7 @@ export default function CompressionPanel() {
const next: CompressionConfig = {
...previous,
...updates,
...(updates.engines ? { engines: { ...previous.engines, ...updates.engines } } : {}),
...(updates.contextBudget
? {
contextBudget: {
Expand Down Expand Up @@ -327,12 +328,7 @@ export default function CompressionPanel() {
};

const setEngine = (id: string, patch: Partial<EngineToggle>) => {
const engines = {
...config.engines,
[id]: { ...(config.engines[id] ?? { enabled: false }), ...patch },
};
// Send the full engines map — the persistence layer stores it as one JSON row.
save({ engines });
save({ engines: { [id]: { ...(config.engines[id] ?? { enabled: false }), ...patch } } });
};

const toggleGuidance = (id: string) => {
Expand Down
33 changes: 32 additions & 1 deletion src/lib/db/compression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,33 @@ function sanitizeEnginesForWrite(value: unknown): Record<string, EngineToggle> {
return out;
}

// Partial engines writes replace the whole JSON row, and the read path turns every id missing
// from a stored row off. Merge each written entry over the stored entry, field by field. While
// no usable row exists, merge over `current`, the map the read path derives from legacy settings.
function mergeEnginesForWrite(
db: ReturnType<typeof getDbInstance>,
value: unknown,
current: Record<string, EngineToggle>
): Record<string, EngineToggle> {
const existingRow = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(NAMESPACE, "engines") as { value: unknown } | undefined;
// getCompressionSettings skips non-text (BLOB) rows, so treat them as absent here too.
const stored =
typeof existingRow?.value === "string"
? parseStoredEnginesMap(parseJsonSafe(existingRow.value))
: null;
const base = stored ?? current;
const incoming = toRecord(value);
const merged: JsonRecord = {};
for (const id of ENGINE_IDS) {
merged[id] = Object.hasOwn(incoming, id)
? { ...base[id], ...toRecord(incoming[id]) }
: base[id];
}
return sanitizeEnginesForWrite(merged);
}

// Partial lite writes replace the whole JSON row. Keep a stored cap unless the
// caller sends maxToolLength: null (clear) or a new in-range integer.
function mergeLiteSettingsForWrite(
Expand Down Expand Up @@ -914,14 +941,18 @@ export async function updateCompressionSettings(
const insert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
);
// The engines map the read path returns now: the merge base for an engines write while no
// engines row exists yet.
const currentEngines: Record<string, EngineToggle> =
updates.engines === undefined ? {} : (await getCompressionSettings()).engines;

const tx = db.transaction(() => {
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) continue;
// Persist the engines map as ONE sanitized JSON row so the read path always gets
// well-formed { enabled, level? } toggles for known engine ids.
if (key === "engines") {
insert.run(NAMESPACE, key, JSON.stringify(sanitizeEnginesForWrite(value)));
insert.run(NAMESPACE, key, JSON.stringify(mergeEnginesForWrite(db, value, currentEngines)));
continue;
}
if (key === "lite") {
Expand Down
15 changes: 12 additions & 3 deletions tests/unit/api/context-combos-default-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import path from "node:path";

import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
import { deriveDefaultPlan } from "@omniroute/open-sse/services/compression/deriveDefaultPlan.ts";
import { ENGINE_IDS } from "@omniroute/open-sse/services/compression/engineCatalog.ts";

// ─── isolated temp DB ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -45,6 +46,14 @@ async function setupAuth(): Promise<void> {
});
}

// An engines write merges over the engines the install already has, so a test that needs only
// some engines on writes every engine id, with the rest off.
function onlyEngines(
enabled: Record<string, { enabled: boolean; level?: string }>
): Record<string, { enabled: boolean; level?: string }> {
return Object.fromEntries(ENGINE_IDS.map((id) => [id, enabled[id] ?? { enabled: false }]));
}

// ─── lifecycle ────────────────────────────────────────────────────────────────

test.beforeEach(async () => {
Expand All @@ -66,7 +75,7 @@ test.after(() => {
test("GET /api/context/combos/default returns the derived default plan (single-mode caveman)", async () => {
await compressionDb.updateCompressionSettings({
enabled: true,
engines: { caveman: { enabled: true, level: "full" } },
engines: onlyEngines({ caveman: { enabled: true, level: "full" } }),
});

const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default");
Expand All @@ -88,10 +97,10 @@ test("GET /api/context/combos/default returns the derived default plan (single-m
test("GET /api/context/combos/default returns the derived stacked pipeline (reflects enabled engines)", async () => {
await compressionDb.updateCompressionSettings({
enabled: true,
engines: {
engines: onlyEngines({
caveman: { enabled: true, level: "full" },
headroom: { enabled: true },
},
}),
});

const req = await makeManagementSessionRequest("http://localhost/api/context/combos/default");
Expand Down
Loading
Loading