Repository navigation
refactor(server): one runtime-settings overlay module - #155
Conversation
tarakanof
left a comment
There was a problem hiding this comment.
Review: #155 (settings overlay, closes #144)
Verdict: merge as-is. No blockers or should-fix items. There are four nits, and none of them blocks the merge.
What I checked
-
Differential test, main vs branch. I wrote a throwaway test and ran the same file on both trees. For all six endpoints (pomodoro, weather, meetings, usage, display, quiet) it covered:
- GET before and after a full PUT
- a full PUT with the body the Swift
ServerConfigModelsends, i.e. every key includingics_urls_configuredandicon_ids:{} - the stored blob after the PUT
- re-apply of 0.28-style blobs that have a missing key and an unknown key
Output was byte-identical on both trees. The only difference was a hand-made usage blob with
usage_threshold_pct:150(see nit 1). -
GET shapes. Unchanged.
viewis the oldxDTO()orcfg.Load().X, moved without changes. -
After hooks. For each setting, the side effects and their order match the old appliers. Examples: weather is
nudgePomothengo ensureNativeIcons; pomodoro is engine update, stop, nudge, icons. They run once per successful put, don't run on a 400, and run outsidecfgMu. The old code had no weather refetch or meetings poll restart, and the new code has none either. -
/admin/reload.reapply()runs aftercfgMu.Unlock(). Pomodoro, weather, meetings, usage, display and quiet keep their relative order.loadPersistedDeviceBaseURLnow runs before the six instead of after meetings. That is harmless, arguably better: the weather icon goroutine now sees the final clock URL. -
Startup. The old code ran
initWeather/initMeetings, which only re-opened the samePomodoro.DBPathstore.loadHiddenAppsis still called byinitPomodoro, and a store-open failure fails the same way either way. -
Concurrency.
tryUpdateConfigholdscfgMuacrossmutate, which now includesPutSetting, a SQLite write. The store has no Go mutex; it usesSetMaxOpenConns(1)and no transactions. No path takes the DB connection and thencfgMu,view/applynever re-entercfgMu, and the after hooks run outside the lock. I found no deadlock. One cost: config writers such as discovery and reload can now wait on a slow DB query. That is acceptable for this workload. -
Usage clamp → 400. The Settings stepper is
0...100. Every storedusage_jsonblob since the threshold was added (f61a264) holds a clamped value, and the seedviewclamps too. So a server-written blob can't hit the new 400. -
Tests.
go test -race -count=2passes on every package exceptember-claude-producer, which I excluded because the branch predates #143. CI is green. The PR merges cleanly into current main. -
Design. The module is deep: six callers supply key/view/apply/after, and merge, atomic swap, persist and re-apply sit behind one seam.
settingsKVhas two real adapters, the store and the testmapKV. The deletion test passes: removing the module would spread the merge and persist logic back into six copies. I agree with keeping device URL and hidden apps out.
Nits
- A stored usage blob out of range now drops the whole override. A stored
usage_jsonwithusage_threshold_pctoutside 0..100 used to be clamped, and its toggles still applied. Now the whole override is ignored and all four fields fall back to the baseline. Only a hand-edited DB can produce such a blob, but it's worth one line under behaviour change 3. - Type errors skip the "request rejected" log line. A body with a wrong type (e.g.
"focus_minutes":"x") now fails inmergeSettingand goes out throughwriteError400. It no longer goes throughrejectBody, so it doesn't log that line. The status is unchanged. - The
appSettingscomment points at the wrong place. It says "The field order below is the reapply order". The order that matters is the element order of the composite literal innewAppSettings. The two match today, but the comment points at the wrong thing to keep in sync. - Behaviour change 7 has no test. Pomodoro now stops a running timer whenever the merged config is disabled. A small test would pin it: PUT
{"enabled":false}while running stops the timer, and a PUT that omitsenabledwhile enabled does not.
Six config slices each hand-copy baseline + store override (merge, validate, persist, re-apply) and the copies have drifted (#144). This adds the one module they will all register with: a typed setting handle whose put merges a JSON object over the effective value (omitted key = unchanged), validates and swaps it atomically under cfgMu, persists the normalised view, and whose overlay re-applies every stored override in one loop. tryUpdateConfig lets the merge-validate-swap run on one snapshot; updateConfig now delegates to it. Tested through the module interface with an in-memory KV adapter.
A partial PUT /v1/display/config decoded into a zero DTO, so an omitted attention_hold_seconds became 0 and failed validation (400). Its stored blob loaded the same way. Moving display, quiet and usage onto the settings overlay gives all three the same merge (omitted field = unchanged) on PUT and on load; the regression test covers the display case. usage_threshold_pct outside 0..100 is now a 400 like every other range check, instead of a silent clamp (the menu's stepper is already 0...100). main.go and /admin/reload re-apply these three through one reapply call.
Both decoded a PUT into a zero config, so a partial body reset every
omitted field to zero or its default, and both carried their own copy of
the persist/load code ("mirrors the Pomodoro store pattern"). They now
register with the settings overlay: a partial PUT merges, and a stored blob
missing a newer key keeps the current value.
initWeather and initMeetings only reopened the store initPomodoro had
already opened (same Pomodoro.DBPath) and loaded one blob each; they are
gone, and main.go/admin.go re-apply both through settings.reapply().
applyPomodoroSettings took cfgMu itself, bypassing updateConfig, to merge and validate on one snapshot; the overlay now does that for every setting, so Pomodoro registers like the rest. Its nil-pointer merge stays as the apply step (explicit null = unchanged). Startup and /admin/reload each re-apply every stored override through one settings.reapply() call; resyncPomodoroAfterReload only re-syncs the engine with the reloaded file config. reapply now runs before the 'pomodoro wired' log line, so it reports the effective enabled flag.
AGENTS.md said only the pomodoro PUT was merge semantics; all six …/config settings PUTs now are. ARCHITECTURE gets a section for the overlay (what a registration supplies, what the module owns, why the clock URL isn't one), RUNBOOK notes the usage 0-100 range check, STYLE lists the file.
…o stop Review of #155: a wrong-typed value failed in mergeSetting and went out through writeError, skipping rejectBody's "request rejected" line that every other undecodable body gets. Decode failures now carry errSettingBody and go through rejectBody; validation errors stay plain 400s. Adds a test that a PUT disabling Pomodoro stops a running timer and one that keeps it enabled doesn't, and corrects the appSettings comment (the reapply order is the register order in newAppSettings).
applyDefaults raised 1..5 to 6 and the docs said 6..24, while the clock's forecastWindow draws any 1..24, and the PUT path didn't check the range at all. File load now keeps 1..24 (0/absent or >24 becomes 24), a settings PUT outside 0..24 is a 400, and 0 there means the full day as at file load.
0fad8c7 to
f3ff28b
Compare
… follow-ups (#159) * fix(weather): preview the draft moon phase and location The Settings Weather pane fetches /v1/weather/preview 300 ms after a draft change, but the autosave debounce is 600 ms and the endpoint read moon_phase and the location from the saved config. Toggling the moon or editing the coordinates rendered the old moon and nothing refreshed it afterwards. The endpoint now takes optional moon_phase and lat/lon draft params (the pair is used only when both parse and are in range, else the saved location), and EmberKit sends them through a WeatherPreviewDraft that holds just the fields that change the frames, so a moon/location edit refetches and a popup or location-name edit does not. * perf(tiles): build the preview frame only for previews tile.view rendered a 32x8 frame for every wanted tile on every device tick, though the coordinator only uses the payload. The frame is now a closure over the same resolved values, called by previewTiles, so parity is unchanged. * test(tiles): drop the duplicate reconcile in TestReconcileTilesNativeIcons Left over from the old weather+forecast pair; the second call only dedupes. * docs: note tileInputs for tiles with new data sources "Adding a tile is one tile value" held only for tiles over existing inputs; a new data source also edits tileInputs and coordinator.tileInputs. Also documents the new weather preview params. * fix(macos): allow 1..24 forecast hours in the Weather pane #155 made the server accept forecast_hours 1..24 (0 = full day). The pane's stepper still offered only 6..24 in steps of 6, and the model comment said 6..24; the stepper now covers the whole range the server takes.
Closes #144
Design summary: see the spec comment on #144.
What
One module,
cmd/ember/settings_overlay.go, owns merge, validation, atomic swap, persistence and re-apply for every runtime-editable setting. Each feature registers asettingSpec[D]:Registered: pomodoro, weather, meetings, usage, display, quiet. The clock URL stays outside: it's a raw string, discovery swaps it in memory, and a reload re-applies it only when the file URL changed. Hidden apps are a set toggle.
Before / after
Behaviour changes
Bug fix (regression test first): a partial
PUT /v1/display/config(e.g.{"attention_chime":false}) merged into a zero DTO and failed the hold-seconds check with a 400. It now changes only the named field. Stored display blobs load the same way.A partial weather or meetings PUT now merges. Before, omitted fields were reset to zero or their defaults.
usage_threshold_pctoutside 0..100 now returns a 400. Before, it was clamped silently. The menu's stepper range is already 0...100. A storedusage_jsonblob with an out-of-range threshold (only a hand-edited DB can hold one) is now ignored whole, so all four fields fall back to the baseline. Before, it was clamped and its toggles still applied.A stored blob that is missing a newer key keeps the current (baseline) value. Quiet and usage already did this; now every setting does.
A settings PUT body must be a JSON object:
null, arrays and strings get a 400. A wrong-typed value is a 400 throughrejectBody, so the "request rejected" log line stays.The stored blob is written under
cfgMu, so racing PUTs can't leave an older value in the store than the one that's live.Pomodoro: the after hook stops a running timer whenever the merged config is disabled, not only when the body named
enabled:false. Normally the two are the same.Startup: the separate "weather/meetings store init failed" warnings are gone, since those functions only reopened the same store
initPomodoroopens. The pomodoro-init warning now says settings won't persist.reapplyruns before the "pomodoro wired" log, so that line reports the effectiveenabled.weather.forecast_hours(review follow-up, flagged by refactor(server): one tile module for push, adopt and preview #145/refactor(server): one tile module for push, adopt and preview #156): the clock'sforecastWindowdraws any 1..24, but file load raised 1..5 to 6. It now keeps 1..24 (0/absent or >24 → 24). A settings PUT outside 0..24 is a 400 (before, there was no check), and 0 means the full day. The docs now say 1..24.Shared-file hunks
main.go: startup block, which runsinitPomodoro→settings.reapply(), with the init/load calls removed.app.go:settingsfield,newAppSettingsinNewApp, andtryUpdateConfig(updateConfigdelegates to it).admin.go: the reload re-apply list becomessettings.reapply().device.go: comment only.coordinator_weather_test.go: one assertion updated (3 hours is kept, not raised to 6).Tests
settings_overlay_test.gotests the module through its interface with an in-memory KV adapter: merge, whole-map replace without aliasing, invalid input changes nothing and persists nothing, case-insensitive keys, reapply from a legacy blob, invalid or missing blobs, and no store.settings_merge_test.gocovers partial weather and meetings PUTs and non-object bodies on all five handlers.display_config_test.gocovers the partial display PUT.applyXSettings/loadPersistedXnow go throughsettings.X.put/settings.reapply().gofmt -lis clean.go vetandgo test -racepass on every package exceptember-claude-producer, which is excluded until fix: keep producer LaunchAgents registered and running (#142) #143. No Swift changes.