Skip to content

fix(dashboard): engine pages save only the fields that changed - #15595

Closed
woodsonl wants to merge 10 commits into
diegosouzapw:release/v3.8.52from
woodsonl:fix/engine-config-page-partial-save
Closed

woodsonl wants to merge 10 commits into
diegosouzapw:release/v3.8.52from
woodsonl:fix/engine-config-page-partial-save

Conversation

@woodsonl

@woodsonl woodsonl commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #15306

Problem

EngineConfigPage holds the detail configuration of the Lite, Aggressive, Ultra, Headroom, Session Dedup and CCR compression engines. On save it PUTs { [subKey]: detail } where detail was the form state seeded once at load. The server (updateCompressionSettings in src/lib/db/compression.ts) replaces each sub-object row whole, and only lite merges, so every save wrote back the whole load-time copy. Three user-visible defects came out of that:

  1. Aggressive page: saving after the Compression Settings tab changed thresholds or tool strategies silently reverted them to the values the engine page had loaded earlier.
  2. Ultra page: every save returned 400 on a default install, because the form sent modelPath: "" while the schema requires z.string().trim().min(1).optional().
  3. Ultra page: once a model path was stored, an engine-page save dropped ultra.enabled, because the form never shows that field.

Fix

Client-side only; updateCompressionSettings is untouched (the server-side merge belongs to fix/compression-nested-partial-writes):

  • Save re-reads the stored settings, then applies only the fields edited since the last load or save onto the fresh stored sub-object (engineConfigSave.ts::buildEngineDetailUpdate).
  • enabled is never written from the form; the stored value passes through.
  • An emptied text field deletes its key (the schemas reject empty strings).
  • The form reports one field at a time and the page applies each change to its latest state, so a keystroke landing while a save finishes can no longer revert fields (EngineConfigForm.tsx).
  • A failed read of the stored settings gets its own message, keeps Save disabled, and can no longer fire from a superseded load.
  • Lite keeps its dedicated body; the server merges lite rows.

Testing (TDD, Hard Rule #18)

Tests first, seen failing, then the fix:

  • tests/unit/ui/engine-config-page-partial-save.test.tsx (vitest, jsdom): 16 tests covering the revert, the default-install 400, enabled, the cleared path, the failed re-read, retries after a rejected save and after a save the server applied but answered 500, and the keystroke race. The pass-2 tests ran red against 2bcf1d8e9a (6 failures across the two UI files) and green after ea900baf14.
  • tests/unit/compression/engine-config-save.test.ts (node:test against the real getCompressionSettings/updateCompressionSettings on a temp DATA_DIR): 14 tests, including a round-trip of every engine sub-object through the strict update schema.

Full gates on ea900baf14: npm run test:vitest 493/493, npm run typecheck:core clean, npm run lint clean, i18n UI keys coverage PASS across 66 locales.

CI note: the vitest .tsx specs run in the test-vitest job on the way to main; the node suite is the one that gates PRs into release/**.

Browser QA

Six probes on an isolated dev server (fresh DATA_DIR, port 20198, gstack headless Chromium): aggressive cross-page preservation, ultra default-install save, model-path clear, llmlingua no-Save, lite toggle persistence, and the settings tab render. All pass. Evidence lives in the session's .gstack/qa-reports/run-20261005T220000Z/ (not committed).

Review

gstack-review ran the specialist passes (security, performance, design and simplification clean; testing and maintainability findings addressed in the last two commits) plus adversarial and red-team passes. Findings fixed here: the settings-load error state (message, cancelled guard), per-field form changes (keystroke race), save retries (rejected, and applied-but-500), comment accuracy. Advisory and out of scope: the client-side read-then-write window (the server-side merge is the other branch's work), the dead per-engine "Preserve system prompt" checkbox, emptied number fields saving as 0, previews ignoring the sent config, and the pre-existing legacy-row read priority. Follow-up tasks are recorded for each.

Notes for reviewers

  • One new en.json string; other locales fall back to English.
  • A changelog.d/fixes/ fragment follows once this PR has a number.

EngineConfigPage seeds its form once at load and PUTs the whole engine
sub-object from that copy. The server replaces the sub-object row, so a
save writes back every field the page loaded, including ones it does not
show.

The new tests reproduce four results of that:
- Aggressive: thresholds, tool strategies, and the summarizer switch
  that the settings tab saved after the page loaded go back to the
  page's copy.
- Ultra, default install: every save returns 400. The form sends
  modelPath "" and the route requires a non-empty string.
- Ultra: a save drops ultra.enabled, because the page strips it from
  the body and the server replaces the row.
- Ultra: emptying the model path returns 400; it should clear the path.

Two more cases pin the fixed behavior: a save whose fresh read fails
writes nothing, and a second save does not resend the first one's field.

The UI test runs the page against a fake route that applies the real
update schema. The node test runs the same flows through the real
settings module and imports the save helper the fix adds, so it fails
on this commit with a missing-module error.
The Ultra page's Preview button sends the whole form as config.ultra,
including the empty model path the form shows by default. The preview
route checks that config against the same strict schema as a save, so
the preview returns 400 and the page shows "Preview failed." on every
install that has no model path set.

The UI test clicks Preview on a default install against a fake route
that applies the preview schema. The node test checks a preview config
built from the seeded form with that schema; it needs the helper the
fix adds.
EngineConfigPage read the settings once at load and PUT the whole
engine sub-object from that copy on every save. The server replaces the
row, so a save wrote back every field the page loaded:

- On the Aggressive page, thresholds, tool strategies, and the
  summarizer switch saved from the settings tab after the page loaded
  went back to the page's copy.
- On the Ultra page, the form's empty model path failed the route's
  non-empty check, so every save on a default install returned 400.
  Once a path was set, a save dropped ultra.enabled, which the page
  strips from the body.

A save now reads the stored sub-object again and applies only the
fields edited since the page loaded or last saved. enabled is never
taken from the form, and an emptied text field removes its key, so
clearing the model path clears it. If that read fails, the save fails
and writes nothing. Lite keeps its own body, since its server write
already merges with the stored row.

Preview had the same empty model path problem: the preview route checks
config against the same schema, so an Ultra preview returned 400 on a
default install. Preview now drops emptied text fields.

The seeding and body logic moves to engineConfigSave.ts so node:test
can cover it next to the UI test.
Build the form defaults with Object.fromEntries, and use configState
directly in handleSave in place of a local alias.
After a save, the engine page kept showing the values it loaded, so a field
another page changed could not be set back to its old value. A save whose
response was lost left the baseline unchanged, so rolling that field back
was never sent. The lite branch wrote back its load-time switch. A failed
settings read at load left the form showing defaults as if they were saved.

The UI tests reproduce each case through the page. The node tests drive the
save helpers against the real settings store, so release PRs gate on them.
After a successful save the page re-seeds the form and its baseline from the
PUT response, keeping any edit typed while the request was out. After a
failed save the baseline drops the fields that save carried, since the
server may have applied it, so the next save sends them again.

Lite builds its body the same way as the other engines: the row stored at
save time plus the fields edited since the last save. The cap is checked
against its range before anything is sent, and a cleared cap sends null.

When the settings read fails at load, the page shows the load error and
keeps Save off, because the form would otherwise show defaults as saved
values.
A rejected PUT must keep the edit for the next save, a PUT the server
applied before answering 500 must be sent again, a settings read that
fails after a reload must not disable the reloaded page, and a keystroke
landing as a save finishes must not revert the other fields. The form
tests now expect per-field change events, and the node suite round-trips
every engine sub-object through the update schema.
The form used to hand the page a whole rebuilt object spread from its
props, so a keystroke landing while a save finished could revert fields
to load-time values and the next save wrote them back. It now reports
single field changes and the page applies them to its latest state.

A failed read of the stored settings now gets its own message instead of
the engine-information one, is set inside the load's cancelled guard so
a superseded load cannot disable Save, and the save-time re-read
tolerates a rejected GET like the load-time one already did.
@woodsonl
woodsonl requested a review from diegosouzapw as a code owner October 5, 2026 22:03
@diegosouzapw

Copy link
Copy Markdown
Owner

Closing as already covered by #15612, now on release/v3.8.52.

src/shared/components/compression/EngineConfigPage.tsx imports engineConfigSave, and tests/unit/ui/engine-config-page-partial-save.test.tsx is on the tip.

Thanks @woodsonl.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants