Skip to content

feat(settings): sync user preferences across authenticated clients - #4954

Open
Beautyl0ve wants to merge 9 commits into
bytedance:mainfrom
Beautyl0ve:feat/deerflow-2595-server-preferences
Open

feat(settings): sync user preferences across authenticated clients#4954
Beautyl0ve wants to merge 9 commits into
bytedance:mainfrom
Beautyl0ve:feat/deerflow-2595-server-preferences

Conversation

@Beautyl0ve

@Beautyl0ve Beautyl0ve commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

feat(settings): sync user preferences across authenticated clients

Fixes #2595

Why

DeerFlow currently stores base UI/model preferences only in one browser's
localStorage. Clearing browser data or signing in on another device loses those
choices. Uploading the existing blob directly would also be unsafe: it is unscoped,
contains state that must remain local, and can be observed by multiple accounts or
tabs on the same origin.

What changed

  • Adds authenticated, owner-scoped GET, first-writer-wins PUT, and nested-merge
    PATCH /api/user-preferences endpoints over a strict browser-safe allowlist.
  • Adds nullable users.preferences JSON plus a revision counter in migration
    0014_user_preferences, branching directly from 0013_mcp_task_notifications.
  • Synchronizes notification-enabled, token-usage display, and base model/context
    defaults. Thread/workspace state, credentials, and browser/OS notification
    permission never enter the contract.
  • Sends leaf-only patches and persists per-leaf mutation/ack slots, so concurrent tabs
    cannot overwrite unrelated settings or acknowledge a newer unsent edit.
  • Serializes each user's bootstrap handshake and writes with Web Locks. Browsers
    without that primitive keep writes local and pending instead of risking reordering.
  • Protects hydration with version checks and activation snapshots, preventing a stale
    request or activation-gap edit from erasing newer local state.
  • Uses bounded revision-CAS retries in the repository, including retrying the complete
    read/merge/CAS cycle for SQLite SQLITE_BUSY and SQLITE_BUSY_SNAPSHOT failures.
  • Treats an existing server value as authoritative. A user without a server record can
    claim a valid legacy browser value once; caches and pending mutations are user-scoped.
  • Sends an expected-user guard with every request. The backend still derives ownership
    only from authentication and rejects a stale tab after another tab changes the
    origin-wide cookie.
  • Keeps auth-disabled/static deployments local-only and documents migration, fallback,
    account isolation, concurrency, and data-boundary behavior.

Surface area

  • Frontend UI — page / component / setting / interaction under frontend/
  • Backend API — endpoint / SSE event / request-response shape under backend/app
  • Agents / LangGraph — agent node, graph wiring, langgraph.json, or prompt change
  • Sandboxdocker/ or sandboxed execution
  • Skills — change under skills/
  • Dependencies — new/upgraded entry in backend/pyproject.toml or frontend/package.json (say what it buys us)
  • Default behavior change — changes existing behavior without the user opting in (default model, default setting, data shape)
  • Docs / tests / CI only — no runtime behavior change

Screenshots / Recording

There is no visual delta: existing settings controls keep their current appearance and
gain authenticated persistence behind the scenes. A real two-profile/browser recording
has not been attached.

Validation

Frontend full unit suite:              134 files / 1,056 tests passed
Targeted settings concurrency suite:   36 tests passed
Frontend TypeScript typecheck:          passed
Changed-file ESLint / Prettier checks: passed

Backend preference/bootstrap/migration suite: 49 tests passed
Upstream-adjacent migration/extension suite:  27 tests passed
Repository guidance tests:                    12 tests passed
Backend Ruff check / format (13 files):        passed

Alembic heads: 0014_user_preferences (single head)
git diff --check: passed

The SQLite concurrency coverage uses two WAL connections and exercises the actual
SQLITE_BUSY_SNAPSHOT code path while verifying that disjoint patches survive. The
migration coverage verifies both a fresh upgrade and downgrade/re-upgrade for existing
users.

Not run: live PostgreSQL transaction integration, a real two-browser/device smoke test,
or frontend build/E2E. PostgreSQL statements were reviewed and dialect-compiled.
Same-field concurrent writes remain last-successful-commit-wins.

Migration publication note: the latest merged main used by this branch ends at
0013_mcp_task_notifications; this PR therefore claims 0014_user_preferences
directly from it. Open PRs #4918
and #4843 currently contain
competing 0014 revisions. Whichever PR lands later must rebase and renumber/rechain
its migration before merge.

AI assistance

Tool(s) used: Codex

How you used it: Assisted with implementation, concurrency review, regression
tests, documentation, and local validation.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head a252e92 (backend: 48 tests in test_user_preferences + persistence bootstrap/migration files pass locally via uv run pytest; frontend: the full new tests/unit/core/settings + layout-boundaries suite passes via rstest). The overall design is solid: the strict allowlist + extra="forbid"/strict=True schemas, owner derivation from the cookie (never the header), the conditional-update initialization, and the version/outbox race handling in UserSettingsSyncLifecycle all check out — I traced the activation-gap, stale-tab, account-switch, and Web-Lock-claim races and they are guarded. Findings below are one correctness gap in the cross-tab echo path plus two robustness notes. The deliberate 0015 revision gap over 0013 is already documented in the PR description and the migration docstring, so no comment there.

Comment thread frontend/src/core/settings/store.ts Outdated
const persisted = readUserSettingsCache(activeBaseSettingsUserId);
if (persisted === null) return;
baseSettings = fromPersistedUserSettings(persisted);
emitBaseSettingsMutation();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cross-tab echo emits a full-state patch (emitBaseSettingsMutation() with no key falls through to toFullUserSettingsPatch, which sends explicit null for every unset context field), and the backend treats null as "delete the key". That can erase server-side values this browser never hydrated: with two tabs open, tab A's scoped edit (e.g. a notification toggle → {notification} patch, model untouched) writes the shared cache; tab B then receives this storage event and PATCHes the entire local state, whose context is empty, so model_name/mode/reasoning_effort are sent as null — clearing a default model that another device set on the server after this browser hydrated. Nobody touched the model on this device, yet the other device's selection is gone (and the DOM test "the same account's tab cache still produces a synchronized mutation" pins the full-patch-with-nulls behavior). Since local edits go through updateLocalSettings(key)/updateThreadSettings(key) and produce section-scoped patches, the storage-event path could do the same by diffing the previous baseSettings snapshot against the freshly read cache and emitting only the sections that changed — that confines the echo to what actually changed and stops the null-clearing clobber.



def _response(settings: dict | None, revision: int) -> UserPreferencesResponse:
validated = UserPreferences.model_validate(settings) if settings is not None else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (robustness/self-heal): _response re-validates the stored JSON with UserPreferences.model_validate, so a stored payload that stops validating — a future enum tightening (this repo already evolved tokenUsage shapes once), or a manual DB fixup — makes GET and PATCH return 500 for that user. Because UserSettingsSyncController.start() swallows every failure and never retries in-session, sync then silently dies, and the client can't recover via PUT (it only PUTs when GET returns settings: null, not when GET 500s). Degrading an invalid stored record to settings: None on GET (keeping revision) would let the normal first-writer-wins PUT re-initialize and self-heal, at the cost of one round-trip.


async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict, int]:
"""Merge with an optimistic revision CAS supported by SQLite/Postgres."""
for _attempt in range(5):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the CAS retry loop does 5 back-to-back attempts with no backoff, and when it exhausts, PATCH surfaces as 409 which the frontend turns into a sticky writeFailed (only cleared by the next user mutation or the next page-load handshake). For a settings blob where contention is rare but possible across Gateway workers, a tiny await asyncio.sleep(...) with jitter between attempts would make exhaustion effectively unreachable; alternatively the frontend could clear writeFailed after a later successful GET.

@Beautyl0ve
Beautyl0ve marked this pull request as ready for review August 23, 2026 03:09
@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only area:frontend Next.js frontend under frontend/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines labels Aug 23, 2026
@WillemJiang

Copy link
Copy Markdown
Collaborator

@Beautyl0ve thanks for your contribution, please resolve the conflicts.

Signed-off-by: 嗜鵼 <hy2010hy2010@qq.com>

# Conflicts:
#	backend/app/gateway/AGENTS.md
#	backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
#	backend/tests/test_migration_0004_run_ownership_dedupe.py
#	backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
#	backend/tests/test_persistence_bootstrap.py
#	backend/tests/test_persistence_bootstrap_concurrency.py
#	backend/tests/test_persistence_bootstrap_regression.py
@Beautyl0ve

Copy link
Copy Markdown
Contributor Author

@WillemJiang Thanks for the reminder. I have merged the latest main and resolved the conflicts. The preferences migration is now 0017_user_preferences, chained after 0016_subagent_batches. I re-ran the focused backend migration/preferences suite (52 tests), frontend settings suite (45 tests), frontend typecheck, guidance checks, Ruff, and Alembic head validation; all pass locally. Please take another look when you have a chance.

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two additional correctness issues found in the settings recovery paths.

try {
const response = await this.transport.patch(patch);
if (response.settings === null) {
const recovered = await this.transport.initialize(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reapply the pending patch after recovery initialization

The Web Lock only serializes tabs in this browser, so another device can initialize the record after this PATCH resets it. Because PUT is first-writer-wins, initialize() can then return that device's existing settings rather than store.getSettings(). This branch accepts any non-null result and subsequently acknowledges the original durable batch, even though patch was never applied to the winner; the user's local edit is then lost on the next hydration. Please reapply patch to the recovered record (or otherwise verify it is present) before acknowledging the batch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 305cc713. After a recovery PATCH returns settings: null, the controller now replays the original pending patch after first-writer-wins initialization and acknowledges the durable batch only after that replay returns a non-null record. The regression test models another device winning initialization without this tab's edit.

"""Deep-merge allowlisted sections; JSON null clears optional fields."""
merged = deepcopy(current)
for section, values in patch.items():
target = merged.setdefault(section, {})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate structurally corrupt settings before merging

The new repair path runs only after merge_user_preferences returns, but this merge assumes every persisted section is a dictionary. For example, a stored payload of {'context': []} makes the assignment below raise TypeError, so PATCH returns 500 before _validated_response can reset the bad revision. The current invalid-record test uses {'context': {}}, which remains mergeable and misses this case. Please validate/type-check the current record before merging, or make a malformed shape enter the reset/reinitialization flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 305cc713. The repository now detects a non-object stored root or patched section before deep-merging and CAS-clears the malformed revision, returning the normal settings: null recovery signal instead of raising TypeError. Added a route regression test with {"context": []}.

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

Labels

area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only area:frontend Next.js frontend under frontend/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

前端 设置 中的 通知开关/模型选择/推理模式等配置目前仅存在localstorage中,未进行持久化

3 participants