From a252e92a4db6420844edb34f114e7b9cf349e9eb Mon Sep 17 00:00:00 2001
From: Beautyl0ve <74452755+Beautyl0ve@users.noreply.github.com>
Date: Sun, 23 Aug 2026 03:05:34 +0800
Subject: [PATCH 1/4] feat(settings): sync user preferences across
authenticated clients
---
README.md | 12 +
backend/app/gateway/AGENTS.md | 1 +
backend/app/gateway/app.py | 4 +
backend/app/gateway/auth/repositories/base.py | 23 +
.../app/gateway/auth/repositories/sqlite.py | 95 +++-
backend/app/gateway/deps.py | 7 +
.../app/gateway/routers/user_preferences.py | 204 +++++++++
.../deerflow/persistence/migrations/AGENTS.md | 1 +
.../versions/0015_user_preferences.py | 39 ++
.../deerflow/persistence/user/model.py | 10 +-
...est_migration_0004_run_ownership_dedupe.py | 2 +-
...ration_0007_scheduled_run_active_dedupe.py | 2 +-
backend/tests/test_persistence_bootstrap.py | 2 +-
.../test_persistence_bootstrap_concurrency.py | 2 +-
.../test_persistence_bootstrap_regression.py | 4 +-
backend/tests/test_user_preferences.py | 336 ++++++++++++++
frontend/README.md | 13 +
frontend/src/AGENTS.md | 28 +-
frontend/src/app/workspace/layout.tsx | 7 +
frontend/src/core/settings/api.ts | 79 ++++
frontend/src/core/settings/persistence.ts | 192 ++++++++
frontend/src/core/settings/store.ts | 271 ++++++++++-
frontend/src/core/settings/sync.ts | 153 +++++++
.../src/core/settings/user-settings-sync.tsx | 96 ++++
.../tests/unit/app/layout-boundaries.test.ts | 8 +
frontend/tests/unit/core/settings/api.test.ts | 69 +++
.../unit/core/settings/persistence.test.ts | 56 +++
.../tests/unit/core/settings/sync.test.ts | 297 ++++++++++++
.../settings/user-settings-sync.dom.test.tsx | 424 ++++++++++++++++++
29 files changed, 2419 insertions(+), 18 deletions(-)
create mode 100644 backend/app/gateway/routers/user_preferences.py
create mode 100644 backend/packages/harness/deerflow/persistence/migrations/versions/0015_user_preferences.py
create mode 100644 backend/tests/test_user_preferences.py
create mode 100644 frontend/src/core/settings/api.ts
create mode 100644 frontend/src/core/settings/persistence.ts
create mode 100644 frontend/src/core/settings/sync.ts
create mode 100644 frontend/src/core/settings/user-settings-sync.tsx
create mode 100644 frontend/tests/unit/core/settings/api.test.ts
create mode 100644 frontend/tests/unit/core/settings/persistence.test.ts
create mode 100644 frontend/tests/unit/core/settings/sync.test.ts
create mode 100644 frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
diff --git a/README.md b/README.md
index 55f871821bd..b49c2e1af93 100644
--- a/README.md
+++ b/README.md
@@ -390,6 +390,18 @@ DeerFlow runs the agent runtime inside the Gateway API. Development mode enables
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx.
+With Gateway authentication enabled, the Web UI also persists an allowlisted
+set of user-level display and model defaults through the owner-scoped
+`/api/user-preferences` API. An existing server record is authoritative at
+sign-in; otherwise DeerFlow imports the browser's valid local base settings
+once. Local storage remains the offline fallback. Thread overrides, browser
+notification permission/system state, workspace data, and credentials stay
+local and are never accepted by this API. Auth-disabled deployments keep the
+original local-only settings behavior. Authenticated fallback caches are keyed
+by account. On browsers with cross-tab Web Locks, only one authenticated
+account can claim the old unscoped cache; without that lock, DeerFlow safely
+skips the ambiguous legacy import instead of copying it across accounts.
+
#### LangGraph Studio (Optional)
The default `make dev` topology uses DeerFlow's Gateway-embedded runtime and
diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md
index e064b9bbb92..7a63de48e67 100644
--- a/backend/app/gateway/AGENTS.md
+++ b/backend/app/gateway/AGENTS.md
@@ -45,6 +45,7 @@ reads/searches.
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | `GET /` - report feature availability for frontend UI gating: hot-reloaded `agents_api`, guarded browser capability, and the startup-scoped durable MCP task capability (enabled config plus SQL repository) |
+| **User Preferences** (`/api/user-preferences`) | Authenticated, owner-scoped `GET` / first-writer-wins `PUT` / nested-merge `PATCH` for the browser-safe user-level settings allowlist. The `users.preferences` JSON record is shared across Gateway workers and guarded by a revision CAS; callers cannot select a user id. The optional expected-user header is only a stale-tab guard compared against the authenticated cookie owner. Keep thread/workspace state, browser permission/system state, and credentials outside this contract. |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py
index 0c3dd6b1e40..5c66c970a62 100644
--- a/backend/app/gateway/app.py
+++ b/backend/app/gateway/app.py
@@ -37,6 +37,7 @@
thread_runs,
threads,
uploads,
+ user_preferences,
)
from app.gateway.trace_middleware import TraceMiddleware, resolve_trace_enabled
from deerflow.config import app_config as deerflow_app_config
@@ -757,6 +758,9 @@ def create_app() -> FastAPI:
# Auth API is mounted at /api/v1/auth
app.include_router(auth.router)
+ # Authenticated user-level UI settings (server-backed, owner-scoped)
+ app.include_router(user_preferences.router)
+
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
app.include_router(feedback.router)
diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py
index 3ec18f75cd4..b5f4324820d 100644
--- a/backend/app/gateway/auth/repositories/base.py
+++ b/backend/app/gateway/auth/repositories/base.py
@@ -15,6 +15,14 @@ class UserNotFoundError(LookupError):
"""
+class UserPreferencesNotInitializedError(LookupError):
+ """Raised when a partial preference update precedes initialization."""
+
+
+class UserPreferencesWriteConflict(RuntimeError):
+ """Raised after bounded optimistic preference-update retries fail."""
+
+
class UserRepository(ABC):
"""Abstract interface for user data storage.
@@ -105,3 +113,18 @@ async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
User if found, None otherwise
"""
raise NotImplementedError
+
+ @abstractmethod
+ async def get_user_preferences(self, user_id: str) -> tuple[dict | None, int]:
+ """Return the user's persisted UI preferences and revision."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def initialize_user_preferences(self, user_id: str, settings: dict) -> tuple[dict, int]:
+ """Persist settings only when the user has no preference record yet."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict, int]:
+ """Atomically deep-merge a validated partial preference update."""
+ raise NotImplementedError
diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py
index c9adeac8e69..39e3a55d7f0 100644
--- a/backend/app/gateway/auth/repositories/sqlite.py
+++ b/backend/app/gateway/auth/repositories/sqlite.py
@@ -12,15 +12,21 @@
from __future__ import annotations
+from copy import deepcopy
from datetime import UTC
from uuid import UUID
-from sqlalchemy import func, select
+from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.gateway.auth.models import User
-from app.gateway.auth.repositories.base import UserNotFoundError, UserRepository
+from app.gateway.auth.repositories.base import (
+ UserNotFoundError,
+ UserPreferencesNotInitializedError,
+ UserPreferencesWriteConflict,
+ UserRepository,
+)
from deerflow.persistence.user.model import UserRow
@@ -179,3 +185,88 @@ async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
+
+ async def get_user_preferences(self, user_id: str) -> tuple[dict | None, int]:
+ stmt = select(UserRow.preferences, UserRow.preferences_revision).where(UserRow.id == user_id)
+ async with self._sf() as session:
+ row = (await session.execute(stmt)).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ preferences, revision = row
+ return deepcopy(preferences), int(revision)
+
+ async def initialize_user_preferences(self, user_id: str, settings: dict) -> tuple[dict, int]:
+ """Set the first preference record without overwriting another client.
+
+ The conditional update is the cross-process arbiter: two tabs or Gateway
+ workers can both observe NULL, but only one can change it. The loser
+ reads and returns the winner's server value.
+ """
+ candidate = deepcopy(settings)
+ async with self._sf() as session:
+ result = await session.execute(
+ update(UserRow)
+ .where(UserRow.id == user_id, UserRow.preferences.is_(None))
+ .values(
+ preferences=candidate,
+ preferences_revision=UserRow.preferences_revision + 1,
+ )
+ )
+ if result.rowcount == 0:
+ row = (await session.execute(select(UserRow.preferences, UserRow.preferences_revision).where(UserRow.id == user_id))).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ existing, revision = row
+ if existing is None:
+ # A concurrent transaction may have lost before committing;
+ # let a normal retry from the client resolve that rare race.
+ raise UserPreferencesWriteConflict(f"Preferences for user {user_id} are being initialized")
+ return deepcopy(existing), int(revision)
+
+ row = (await session.execute(select(UserRow.preferences, UserRow.preferences_revision).where(UserRow.id == user_id))).one()
+ await session.commit()
+ stored, revision = row
+ return deepcopy(stored), int(revision)
+
+ 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):
+ async with self._sf() as session:
+ row = (await session.execute(select(UserRow.preferences, UserRow.preferences_revision).where(UserRow.id == user_id))).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ current, revision = row
+ if current is None:
+ raise UserPreferencesNotInitializedError(f"Preferences for user {user_id} have not been initialized")
+
+ merged = _merge_preferences(current, patch)
+ result = await session.execute(
+ update(UserRow)
+ .where(
+ UserRow.id == user_id,
+ UserRow.preferences_revision == revision,
+ )
+ .values(
+ preferences=merged,
+ preferences_revision=revision + 1,
+ )
+ )
+ if result.rowcount == 1:
+ await session.commit()
+ return merged, int(revision) + 1
+ await session.rollback()
+
+ raise UserPreferencesWriteConflict(f"Concurrent preference updates for user {user_id} did not settle")
+
+
+def _merge_preferences(current: dict, patch: dict) -> dict:
+ """Deep-merge allowlisted sections; JSON null clears optional fields."""
+ merged = deepcopy(current)
+ for section, values in patch.items():
+ target = merged.setdefault(section, {})
+ for key, value in values.items():
+ if value is None:
+ target.pop(key, None)
+ else:
+ target[key] = deepcopy(value)
+ return merged
diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py
index d8bb8c3ec18..37ea75a9c57 100644
--- a/backend/app/gateway/deps.py
+++ b/backend/app/gateway/deps.py
@@ -732,6 +732,13 @@ def get_local_provider() -> LocalAuthProvider:
return _cached_local_provider
+def get_user_repository() -> SQLiteUserRepository:
+ """Return the shared SQL-backed user repository after engine startup."""
+ get_local_provider()
+ assert _cached_repo is not None
+ return _cached_repo
+
+
async def get_current_user_from_request(request: Request):
"""Get the current authenticated user from the request cookie.
diff --git a/backend/app/gateway/routers/user_preferences.py b/backend/app/gateway/routers/user_preferences.py
new file mode 100644
index 00000000000..b4a24f40e17
--- /dev/null
+++ b/backend/app/gateway/routers/user_preferences.py
@@ -0,0 +1,204 @@
+"""Authenticated, server-backed user-level UI preferences."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
+
+from app.gateway.auth.repositories.base import (
+ UserNotFoundError,
+ UserPreferencesNotInitializedError,
+ UserPreferencesWriteConflict,
+)
+from app.gateway.deps import get_current_user_from_request, get_user_repository
+
+router = APIRouter(prefix="/api/user-preferences", tags=["user-preferences"])
+
+MAX_USER_PREFERENCES_BYTES = 2048
+EXPECTED_USER_ID_HEADER = "X-DeerFlow-Expected-User-Id"
+ModelName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=256)]
+
+
+def _is_none(value: object) -> bool:
+ return value is None
+
+
+class _StrictModel(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True)
+
+
+class NotificationPreferences(_StrictModel):
+ enabled: bool
+
+
+class TokenUsagePreferences(_StrictModel):
+ headerTotal: bool
+ inlineMode: Literal["off", "per_turn", "step_debug"]
+
+
+class ContextPreferences(_StrictModel):
+ model_name: ModelName | None = Field(default=None, exclude_if=_is_none)
+ mode: Literal["flash", "thinking", "pro", "ultra"] | None = Field(default=None, exclude_if=_is_none)
+ reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = Field(
+ default=None,
+ exclude_if=_is_none,
+ )
+
+
+class UserPreferences(_StrictModel):
+ notification: NotificationPreferences
+ tokenUsage: TokenUsagePreferences
+ context: ContextPreferences
+
+ @model_validator(mode="after")
+ def enforce_size_limit(self) -> UserPreferences:
+ if len(self.model_dump_json(exclude_none=True).encode("utf-8")) > MAX_USER_PREFERENCES_BYTES:
+ raise ValueError("User preferences exceed the size limit")
+ return self
+
+ def to_storage_dict(self) -> dict:
+ return self.model_dump(exclude_none=True)
+
+
+class NotificationPreferencesPatch(_StrictModel):
+ enabled: bool | None = None
+
+ @model_validator(mode="after")
+ def require_field(self) -> NotificationPreferencesPatch:
+ if not self.model_fields_set:
+ raise ValueError("At least one notification preference is required")
+ if self.enabled is None:
+ raise ValueError("notification.enabled cannot be null")
+ return self
+
+
+class TokenUsagePreferencesPatch(_StrictModel):
+ headerTotal: bool | None = None
+ inlineMode: Literal["off", "per_turn", "step_debug"] | None = None
+
+ @model_validator(mode="after")
+ def require_field(self) -> TokenUsagePreferencesPatch:
+ if not self.model_fields_set:
+ raise ValueError("At least one token-usage preference is required")
+ if "headerTotal" in self.model_fields_set and self.headerTotal is None:
+ raise ValueError("tokenUsage.headerTotal cannot be null")
+ if "inlineMode" in self.model_fields_set and self.inlineMode is None:
+ raise ValueError("tokenUsage.inlineMode cannot be null")
+ return self
+
+
+class ContextPreferencesPatch(_StrictModel):
+ model_name: ModelName | None = None
+ mode: Literal["flash", "thinking", "pro", "ultra"] | None = None
+ reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
+
+ @model_validator(mode="after")
+ def require_field(self) -> ContextPreferencesPatch:
+ if not self.model_fields_set:
+ raise ValueError("At least one context preference is required")
+ return self
+
+
+class UserPreferencesPatchRequest(_StrictModel):
+ notification: NotificationPreferencesPatch | None = None
+ tokenUsage: TokenUsagePreferencesPatch | None = None
+ context: ContextPreferencesPatch | None = None
+
+ @model_validator(mode="after")
+ def require_patch_and_enforce_size(self) -> UserPreferencesPatchRequest:
+ if not self.model_fields_set:
+ raise ValueError("At least one preference section is required")
+ if any(getattr(self, field) is None for field in self.model_fields_set):
+ raise ValueError("Preference sections cannot be null")
+ if len(self.model_dump_json(exclude_unset=True).encode("utf-8")) > MAX_USER_PREFERENCES_BYTES:
+ raise ValueError("User preferences patch exceeds the size limit")
+ return self
+
+ def to_storage_patch(self) -> dict:
+ return self.model_dump(exclude_unset=True)
+
+
+class UserPreferencesInitializeRequest(_StrictModel):
+ settings: UserPreferences
+
+
+class UserPreferencesResponse(_StrictModel):
+ settings: UserPreferences | None
+ revision: int = Field(ge=0)
+
+
+def _response(settings: dict | None, revision: int) -> UserPreferencesResponse:
+ validated = UserPreferences.model_validate(settings) if settings is not None else None
+ return UserPreferencesResponse(settings=validated, revision=revision)
+
+
+def _translate_repository_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, UserNotFoundError):
+ return HTTPException(status_code=404, detail="User not found")
+ if isinstance(exc, UserPreferencesNotInitializedError):
+ return HTTPException(status_code=409, detail="User preferences must be initialized before partial updates")
+ if isinstance(exc, UserPreferencesWriteConflict):
+ return HTTPException(status_code=409, detail="Concurrent user-preference update; retry the request")
+ raise exc
+
+
+async def _get_guarded_user(request: Request):
+ """Resolve the authenticated owner and reject a tab with a stale session.
+
+ Browser cookies are origin-wide. If another tab signs into a different
+ account, a still-mounted settings controller retains its old user id while
+ subsequent requests carry the new cookie. The expected id is only a guard;
+ repository ownership always comes from the authenticated request.
+ """
+ user = await get_current_user_from_request(request)
+ expected_user_id = request.headers.get(EXPECTED_USER_ID_HEADER)
+ if expected_user_id is not None and expected_user_id != str(user.id):
+ raise HTTPException(status_code=409, detail="Authenticated user changed; reload before synchronizing settings")
+ return user
+
+
+@router.get("", response_model=UserPreferencesResponse)
+async def get_user_preferences(request: Request) -> UserPreferencesResponse:
+ """Return preferences for the authenticated user only."""
+ user = await _get_guarded_user(request)
+ try:
+ settings, revision = await get_user_repository().get_user_preferences(str(user.id))
+ except Exception as exc:
+ raise _translate_repository_error(exc) from exc
+ return _response(settings, revision)
+
+
+@router.put("", response_model=UserPreferencesResponse)
+async def initialize_user_preferences(
+ body: UserPreferencesInitializeRequest,
+ request: Request,
+) -> UserPreferencesResponse:
+ """First-writer-wins import of the legacy local base settings."""
+ user = await _get_guarded_user(request)
+ try:
+ settings, revision = await get_user_repository().initialize_user_preferences(
+ str(user.id),
+ body.settings.to_storage_dict(),
+ )
+ except Exception as exc:
+ raise _translate_repository_error(exc) from exc
+ return _response(settings, revision)
+
+
+@router.patch("", response_model=UserPreferencesResponse)
+async def patch_user_preferences(
+ body: UserPreferencesPatchRequest,
+ request: Request,
+) -> UserPreferencesResponse:
+ """Deep-merge an allowlisted patch for the authenticated user."""
+ user = await _get_guarded_user(request)
+ try:
+ settings, revision = await get_user_repository().merge_user_preferences(
+ str(user.id),
+ body.to_storage_patch(),
+ )
+ except Exception as exc:
+ raise _translate_repository_error(exc) from exc
+ return _response(settings, revision)
diff --git a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
index 2369c3723d3..9e5f5f36a79 100644
--- a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
+++ b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
@@ -37,5 +37,6 @@ This invokes `alembic revision --autogenerate` against the live ORM models. Revi
- `migrations/versions/0011_mcp_tasks.py` — creates the durable long-running MCP task table and its user/server/remote uniqueness constraint
- `migrations/versions/0012_mcp_task_results.py` — adds bounded result preview/truncation/artifact fields for ordinary task drivers
- `migrations/versions/0013_mcp_task_notifications.py` — adds durable Agent-run notification snapshots, delivery leases, idempotency fields, and the separate bounded-retry attempt counter
+- `migrations/versions/0015_user_preferences.py` — adds nullable user-level preference JSON plus its optimistic concurrency revision; both columns use idempotent add/drop helpers. Its current Draft base remains `0013_mcp_task_notifications`; rebase the `down_revision` onto whichever open `0014` migration lands before this PR is made Ready.
- `persistence/bootstrap.py` — `bootstrap_schema(engine, backend=...)`, the three-branch decision + locking
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)
diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0015_user_preferences.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0015_user_preferences.py
new file mode 100644
index 00000000000..f70d5c5334e
--- /dev/null
+++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0015_user_preferences.py
@@ -0,0 +1,39 @@
+"""Add durable user-level UI preferences.
+
+Revision ID: 0015_user_preferences
+Revises: 0013_mcp_task_notifications
+Create Date: 2026-08-23
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+revision: str = "0015_user_preferences"
+down_revision: str | Sequence[str] | None = "0013_mcp_task_notifications"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ from deerflow.persistence.migrations._helpers import safe_add_column
+
+ safe_add_column("users", sa.Column("preferences", sa.JSON(), nullable=True))
+ safe_add_column(
+ "users",
+ sa.Column(
+ "preferences_revision",
+ sa.Integer(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+
+def downgrade() -> None:
+ from deerflow.persistence.migrations._helpers import safe_drop_column
+
+ safe_drop_column("users", "preferences_revision")
+ safe_drop_column("users", "preferences")
diff --git a/backend/packages/harness/deerflow/persistence/user/model.py b/backend/packages/harness/deerflow/persistence/user/model.py
index 130d4bfcba3..cdd3817bc05 100644
--- a/backend/packages/harness/deerflow/persistence/user/model.py
+++ b/backend/packages/harness/deerflow/persistence/user/model.py
@@ -13,7 +13,7 @@
from datetime import UTC, datetime
-from sqlalchemy import Boolean, DateTime, Index, String, text
+from sqlalchemy import JSON, Boolean, DateTime, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -48,6 +48,14 @@ class UserRow(Base):
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
token_version: Mapped[int] = mapped_column(nullable=False, default=0)
+ # Browser-safe, user-level UI preferences. The API owns a strict allowlist;
+ # this JSON column must never receive credentials, browser permission state,
+ # or thread/workspace-scoped data. NULL distinguishes "never migrated" from
+ # a stored preference object so the frontend can perform a one-time import
+ # from its legacy localStorage value.
+ preferences: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ preferences_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+
__table_args__ = (
Index(
"idx_users_oauth_identity",
diff --git a/backend/tests/test_migration_0004_run_ownership_dedupe.py b/backend/tests/test_migration_0004_run_ownership_dedupe.py
index 8d1d14c817a..8727307448d 100644
--- a/backend/tests/test_migration_0004_run_ownership_dedupe.py
+++ b/backend/tests/test_migration_0004_run_ownership_dedupe.py
@@ -157,7 +157,7 @@ async def test_migration_dedupes_duplicate_active_rows_before_unique_index(tmp_p
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
# Bootstrap upgrades through the later revisions after 0004.
- assert version_row[0] == "0013_mcp_task_notifications"
+ assert version_row[0] == "0015_user_preferences"
# Sanity: the invariant the index enforces is now true — at most one
# active row per thread.
diff --git a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
index 9e5718f70e9..2472e23ba4e 100644
--- a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
+++ b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
@@ -169,7 +169,7 @@ async def test_migration_supersedes_duplicate_active_runs_before_unique_index(tm
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0013_mcp_task_notifications"
+ assert version_row[0] == "0015_user_preferences"
# Sanity: the invariant the index enforces now holds — at most one
# active row per task_id.
diff --git a/backend/tests/test_persistence_bootstrap.py b/backend/tests/test_persistence_bootstrap.py
index 46a6e20934b..01bd8bc4719 100644
--- a/backend/tests/test_persistence_bootstrap.py
+++ b/backend/tests/test_persistence_bootstrap.py
@@ -48,7 +48,7 @@
asyncio_test = pytest.mark.asyncio
-HEAD = "0013_mcp_task_notifications"
+HEAD = "0015_user_preferences"
BASELINE = "0001_baseline"
diff --git a/backend/tests/test_persistence_bootstrap_concurrency.py b/backend/tests/test_persistence_bootstrap_concurrency.py
index b2c5e832e1d..786de0cfefa 100644
--- a/backend/tests/test_persistence_bootstrap_concurrency.py
+++ b/backend/tests/test_persistence_bootstrap_concurrency.py
@@ -28,7 +28,7 @@
pytestmark = pytest.mark.asyncio
-HEAD = "0013_mcp_task_notifications"
+HEAD = "0015_user_preferences"
def _url(tmp_path: Path) -> str:
diff --git a/backend/tests/test_persistence_bootstrap_regression.py b/backend/tests/test_persistence_bootstrap_regression.py
index 794d22274c7..b7ffb484940 100644
--- a/backend/tests/test_persistence_bootstrap_regression.py
+++ b/backend/tests/test_persistence_bootstrap_regression.py
@@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No
cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
assert "token_usage_by_model" in cols
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0013_mcp_task_notifications"
+ assert version_row[0] == "0015_user_preferences"
# And the read path that originally 500'd must now succeed.
sf = get_session_factory()
@@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path
# No duplicate column -- list, not set, to catch dupes.
assert cols.count("token_usage_by_model") == 1
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0013_mcp_task_notifications"
+ assert version_row[0] == "0015_user_preferences"
finally:
await close_engine()
diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py
new file mode 100644
index 00000000000..706e1d94a26
--- /dev/null
+++ b/backend/tests/test_user_preferences.py
@@ -0,0 +1,336 @@
+"""User-level settings persistence contract (issue #2595)."""
+
+from __future__ import annotations
+
+import asyncio
+import importlib.util
+from pathlib import Path
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import uuid4
+
+import pytest
+import pytest_asyncio
+import sqlalchemy as sa
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from fastapi import FastAPI, HTTPException
+from fastapi.testclient import TestClient
+from pydantic import ValidationError
+
+from app.gateway.auth.models import User
+from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
+from app.gateway.routers import user_preferences as user_preferences_router
+from app.gateway.routers.user_preferences import (
+ EXPECTED_USER_ID_HEADER,
+ UserPreferencesInitializeRequest,
+ UserPreferencesPatchRequest,
+ get_user_preferences,
+)
+from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+
+
+def _full_preferences(*, model_name: str = "model-a") -> dict:
+ return {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "per_turn"},
+ "context": {
+ "model_name": model_name,
+ "mode": "thinking",
+ "reasoning_effort": "medium",
+ },
+ }
+
+
+@pytest_asyncio.fixture
+async def user_repository(tmp_path: Path):
+ db_path = tmp_path / "preferences.db"
+ await init_engine(
+ "sqlite",
+ url=f"sqlite+aiosqlite:///{db_path}",
+ sqlite_dir=str(tmp_path),
+ )
+ try:
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ yield SQLiteUserRepository(session_factory)
+ finally:
+ await close_engine()
+
+
+async def _create_user(repository: SQLiteUserRepository, email: str) -> User:
+ user = User(id=uuid4(), email=email, password_hash="hash")
+ await repository.create_user(user)
+ return user
+
+
+def _load_user_preferences_migration() -> ModuleType:
+ migration_path = Path(__file__).parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "versions" / "0015_user_preferences.py"
+ spec = importlib.util.spec_from_file_location("migration_0015_user_preferences", migration_path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.mark.asyncio
+async def test_preferences_are_isolated_by_authenticated_user(user_repository: SQLiteUserRepository) -> None:
+ alice = await _create_user(user_repository, "alice@example.com")
+ bob = await _create_user(user_repository, "bob@example.com")
+
+ await user_repository.initialize_user_preferences(str(alice.id), _full_preferences(model_name="alice-model"))
+ await user_repository.initialize_user_preferences(str(bob.id), _full_preferences(model_name="bob-model"))
+
+ alice_preferences, _alice_revision = await user_repository.get_user_preferences(str(alice.id))
+ bob_preferences, _bob_revision = await user_repository.get_user_preferences(str(bob.id))
+
+ assert alice_preferences is not None
+ assert bob_preferences is not None
+ assert alice_preferences["context"]["model_name"] == "alice-model"
+ assert bob_preferences["context"]["model_name"] == "bob-model"
+
+
+@pytest.mark.asyncio
+async def test_initialization_is_first_writer_wins(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "migration@example.com")
+
+ (first, first_revision), (second, second_revision) = await asyncio.gather(
+ user_repository.initialize_user_preferences(
+ str(user.id),
+ _full_preferences(model_name="first"),
+ ),
+ user_repository.initialize_user_preferences(
+ str(user.id),
+ _full_preferences(model_name="second"),
+ ),
+ )
+
+ assert first["context"]["model_name"] in {"first", "second"}
+ assert second["context"]["model_name"] == first["context"]["model_name"]
+ assert first_revision == 1
+ assert second_revision == first_revision
+
+
+@pytest.mark.asyncio
+async def test_partial_update_merges_nested_sections(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "merge@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ merged, revision = await user_repository.merge_user_preferences(
+ str(user.id),
+ {
+ "tokenUsage": {"inlineMode": "step_debug"},
+ "context": {"reasoning_effort": "high"},
+ },
+ )
+
+ assert revision == 2
+ assert merged == {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "step_debug"},
+ "context": {
+ "model_name": "model-a",
+ "mode": "thinking",
+ "reasoning_effort": "high",
+ },
+ }
+
+
+@pytest.mark.asyncio
+async def test_patch_can_clear_optional_context_values(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "clear@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ merged, _revision = await user_repository.merge_user_preferences(
+ str(user.id),
+ {"context": {"model_name": None, "reasoning_effort": None}},
+ )
+
+ assert "model_name" not in merged["context"]
+ assert "reasoning_effort" not in merged["context"]
+ assert merged["context"]["mode"] == "thinking"
+
+
+@pytest.mark.asyncio
+async def test_concurrent_disjoint_updates_do_not_lose_fields(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "concurrent@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ await asyncio.gather(
+ user_repository.merge_user_preferences(
+ str(user.id),
+ {"notification": {"enabled": False}},
+ ),
+ user_repository.merge_user_preferences(
+ str(user.id),
+ {"tokenUsage": {"inlineMode": "off"}},
+ ),
+ )
+
+ stored, revision = await user_repository.get_user_preferences(str(user.id))
+ assert stored is not None
+ assert stored["notification"]["enabled"] is False
+ assert stored["tokenUsage"]["inlineMode"] == "off"
+ assert revision == 3
+
+
+def test_preferences_schema_rejects_unknown_private_or_system_fields() -> None:
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"context": {"thread_id": "private-thread"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"notification": {"permission": "granted"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"token": "secret"},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"user_id": str(uuid4()), "notification": {"enabled": False}},
+ )
+
+
+def test_preferences_schema_rejects_oversized_or_invalid_values() -> None:
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"context": {"model_name": "x" * 257}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"tokenUsage": {"inlineMode": "verbose"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"notification": {"enabled": "yes"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate({"notification": None})
+
+
+def test_initialize_requires_complete_valid_base_settings() -> None:
+ request = UserPreferencesInitializeRequest.model_validate({"settings": _full_preferences()})
+ assert request.settings.context.model_name == "model-a"
+
+ with pytest.raises(ValidationError):
+ UserPreferencesInitializeRequest.model_validate(
+ {"settings": {"context": {"model_name": "model-a"}}},
+ )
+
+
+@pytest.mark.asyncio
+async def test_route_derives_owner_from_authenticated_request(monkeypatch: pytest.MonkeyPatch) -> None:
+ user = User(id=uuid4(), email="owner@example.com", password_hash="hash")
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ repository = SimpleNamespace(
+ get_user_preferences=AsyncMock(
+ return_value=(_full_preferences(model_name="owner-model"), 3),
+ ),
+ )
+ monkeypatch.setattr(
+ "app.gateway.routers.user_preferences.get_user_repository",
+ lambda: repository,
+ )
+
+ response = await get_user_preferences(request) # type: ignore[arg-type]
+
+ repository.get_user_preferences.assert_awaited_once_with(str(user.id))
+ assert response.settings is not None
+ assert response.settings.context.model_name == "owner-model"
+
+
+@pytest.mark.asyncio
+async def test_route_rejects_a_stale_tab_after_the_session_owner_changes(monkeypatch: pytest.MonkeyPatch) -> None:
+ current_user = User(id=uuid4(), email="current@example.com", password_hash="hash")
+ stale_user_id = str(uuid4())
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=current_user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={EXPECTED_USER_ID_HEADER: stale_user_id},
+ )
+ repository = SimpleNamespace(get_user_preferences=AsyncMock())
+ monkeypatch.setattr(
+ "app.gateway.routers.user_preferences.get_user_repository",
+ lambda: repository,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_user_preferences(request) # type: ignore[arg-type]
+
+ assert exc_info.value.status_code == 409
+ repository.get_user_preferences.assert_not_awaited()
+
+
+def test_http_response_keeps_absent_record_explicit_and_omits_unset_context_values(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = User(id=uuid4(), email="wire@example.com", password_hash="hash")
+ repository = SimpleNamespace(get_user_preferences=AsyncMock(return_value=(None, 0)))
+ monkeypatch.setattr(user_preferences_router, "get_current_user_from_request", AsyncMock(return_value=user))
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: repository)
+ app = FastAPI()
+ app.include_router(user_preferences_router.router)
+
+ response = TestClient(app).get("/api/user-preferences")
+
+ assert response.status_code == 200
+ assert response.json() == {"settings": None, "revision": 0}
+
+ repository.get_user_preferences = AsyncMock(
+ return_value=(
+ {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "per_turn"},
+ "context": {},
+ },
+ 1,
+ )
+ )
+
+ response = TestClient(app).get("/api/user-preferences")
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "settings": {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "per_turn"},
+ "context": {},
+ },
+ "revision": 1,
+ }
+
+
+def test_user_preferences_migration_is_idempotent_and_reversible(tmp_path: Path) -> None:
+ migration = _load_user_preferences_migration()
+
+ db_path = tmp_path / "migration.db"
+ engine = sa.create_engine(f"sqlite:///{db_path}")
+ with engine.begin() as connection:
+ connection.execute(sa.text("CREATE TABLE users (id VARCHAR(36) PRIMARY KEY, email VARCHAR(320) NOT NULL)"))
+ context = MigrationContext.configure(connection)
+ with Operations.context(context):
+ migration.upgrade()
+ migration.upgrade()
+
+ columns = {column["name"] for column in sa.inspect(connection).get_columns("users")}
+ assert {"preferences", "preferences_revision"} <= columns
+
+ with Operations.context(context):
+ migration.downgrade()
+ migration.downgrade()
+
+ columns = {column["name"] for column in sa.inspect(connection).get_columns("users")}
+ assert columns == {"id", "email"}
diff --git a/frontend/README.md b/frontend/README.md
index 7660d8e34aa..f788260cc1c 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -77,6 +77,19 @@ pnpm start
## Configuration
+### Settings Persistence
+
+When Gateway authentication is enabled, DeerFlow synchronizes a small,
+browser-safe allowlist of base UI settings across signed-in sessions. Existing
+server settings win on sign-in; accounts without a record import their valid
+local settings once. Local storage remains the non-blocking offline fallback,
+and per-thread model overrides, browser notification permission, workspace
+state, and credentials never enter this synchronization API. Auth-disabled
+deployments retain the original local-only behavior. Authenticated fallback
+caches and failed-write outboxes are account-scoped. A cross-tab Web Lock gives
+the old unscoped cache a single owner during upgrade; browsers without Web
+Locks skip that ambiguous import and start from defaults until server hydration.
+
### Environment Variables
Key environment variables (see `.env.example` for full list):
diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md
index 383e78d69f6..5af6cffecc3 100644
--- a/frontend/src/AGENTS.md
+++ b/frontend/src/AGENTS.md
@@ -13,7 +13,33 @@
ownership and returns 206/416 through `FileResponse`.
3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. The resolver suppresses checkpoint/transient prefixes whose canonical position is still behind an unloaded cursor page instead of collapsing that unknown gap before a recent anchor, then adds optimistic messages without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded. Dynamic context re-keys the submitted user message from `X` to `X__user`; UI identity matching normalizes that reserved suffix only for human messages so the submitted frame and checkpoint replacement remain one visible turn. A locally submitted turn also records its pre-submit identity baseline: if `messages-tuple` publishes new AI/tool steps before `values` publishes that turn's human message, render ordering moves only those non-baseline visible steps behind the new human while leaving history, hidden controls, and reconnected runs untouched. Keep that local order anchor through finish, stop, and stream error because the SDK's settled frame can retain transient event order; replace it on the next local submit and clear it on thread switch or replay-gap recovery.
4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits
-5. TanStack Query manages server state; localStorage stores user settings. The
+5. TanStack Query manages server state. `core/settings` keeps user settings in
+ localStorage as an offline fallback; in normal authenticated Gateway mode,
+ `UserSettingsSync` hydrates the browser-safe base-settings allowlist from
+ `GET /api/user-preferences`, or performs a first-writer-wins import of a
+ valid legacy local value when no server record exists. Later base mutations
+ are serialized as nested `PATCH` writes, and response values never overwrite
+ a newer local edit. Thread model override keys/ids remain local, as do browser
+ notification permission/system state and all workspace/credential data.
+ Every active-user mutation is captured at the store boundary and enters a
+ user-scoped, allowlisted local outbox before async activation or network
+ observers can run. If a setting changes between `UserSettingsSync` render
+ and activation, startup also seeds a full current-state patch before
+ hydrating the server response, retaining that patch in memory when browser
+ storage rejects the durable outbox write.
+ Failed writes remain in that outbox; the next handshake folds them over the
+ server read and retries before clearing, so reconnect/reload cannot silently
+ erase an unsynchronized local selection. Keep `UserSettingsSync` mounted
+ before interactive workspace content so its render-time version boundary is
+ established before settings controls render.
+ Authenticated fallback caches are also keyed by user. A Web Lock serializes
+ the one-time claim of the historical unscoped cache across tabs; without Web
+ Locks, the ambiguous legacy value is not imported. Storage events for a
+ different user's cache are ignored, and every API call sends an expected-user
+ guard that the Gateway compares with the authenticated cookie owner, so a tab
+ left open across an account switch cannot read or patch the new account.
+ Auth-disabled and static-website modes mount no settings synchronization and
+ retain the prior local-only behavior. The
Settings > Tools MCP switch calls the targeted `PATCH /api/mcp/config`
mutation, disables switches until that mutation's success refetch completes,
displays the backend error `detail` through a toast, and invalidates
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx
index 2fa583bd9b9..57749ae9a58 100644
--- a/frontend/src/app/workspace/layout.tsx
+++ b/frontend/src/app/workspace/layout.tsx
@@ -4,11 +4,14 @@ import "streamdown/styles.css";
import { redirect } from "next/navigation";
import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
+import { isAuthDisabledMode } from "@/core/auth/auth-disabled-user";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
+import { UserSettingsSync } from "@/core/settings/user-settings-sync";
+import { isStaticWebsiteOnly } from "@/core/static-mode";
import { WorkspaceContent } from "./workspace-content";
@@ -26,6 +29,10 @@ export default async function WorkspaceLayout({
case "authenticated":
content = (
+
{children}
);
diff --git a/frontend/src/core/settings/api.ts b/frontend/src/core/settings/api.ts
new file mode 100644
index 00000000000..a54608f1bdf
--- /dev/null
+++ b/frontend/src/core/settings/api.ts
@@ -0,0 +1,79 @@
+import { z } from "zod";
+
+import { throwGatewayApiError } from "@/core/api/errors";
+import { fetch } from "@/core/api/fetcher";
+import { getBackendBaseURL } from "@/core/config";
+
+import {
+ persistedUserSettingsSchema,
+ type PersistedUserSettings,
+ type PersistedUserSettingsPatch,
+} from "./persistence";
+
+const responseSchema = z
+ .object({
+ settings: persistedUserSettingsSchema.nullable(),
+ revision: z.number().int().nonnegative(),
+ })
+ .strict();
+
+export type UserSettingsResponse = z.infer;
+const EXPECTED_USER_ID_HEADER = "X-DeerFlow-Expected-User-Id";
+
+function url(): string {
+ return `${getBackendBaseURL()}/api/user-preferences`;
+}
+
+async function parseResponse(
+ response: Response,
+): Promise {
+ if (!response.ok) {
+ await throwGatewayApiError(
+ response,
+ `Failed to synchronize user settings: ${response.statusText}`,
+ );
+ }
+ return responseSchema.parse(await response.json());
+}
+
+export async function fetchUserSettings(
+ expectedUserId: string,
+): Promise {
+ return parseResponse(
+ await fetch(url(), {
+ headers: { [EXPECTED_USER_ID_HEADER]: expectedUserId },
+ }),
+ );
+}
+
+export async function initializeUserSettings(
+ expectedUserId: string,
+ settings: PersistedUserSettings,
+): Promise {
+ return parseResponse(
+ await fetch(url(), {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ [EXPECTED_USER_ID_HEADER]: expectedUserId,
+ },
+ body: JSON.stringify({ settings }),
+ }),
+ );
+}
+
+export async function patchUserSettings(
+ expectedUserId: string,
+ patch: PersistedUserSettingsPatch,
+): Promise {
+ return parseResponse(
+ await fetch(url(), {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ [EXPECTED_USER_ID_HEADER]: expectedUserId,
+ },
+ body: JSON.stringify(patch),
+ }),
+ );
+}
diff --git a/frontend/src/core/settings/persistence.ts b/frontend/src/core/settings/persistence.ts
new file mode 100644
index 00000000000..878ee5f0fd0
--- /dev/null
+++ b/frontend/src/core/settings/persistence.ts
@@ -0,0 +1,192 @@
+import { z } from "zod";
+
+import type { LocalSettings } from "./local";
+
+const modelNameSchema = z.string().trim().min(1).max(256);
+const modeSchema = z.enum(["flash", "thinking", "pro", "ultra"]);
+const reasoningEffortSchema = z.enum(["minimal", "low", "medium", "high"]);
+const inlineModeSchema = z.enum(["off", "per_turn", "step_debug"]);
+
+export const persistedUserSettingsSchema = z
+ .object({
+ notification: z.object({ enabled: z.boolean() }).strict(),
+ tokenUsage: z
+ .object({
+ headerTotal: z.boolean(),
+ inlineMode: inlineModeSchema,
+ })
+ .strict(),
+ context: z
+ .object({
+ model_name: modelNameSchema.optional(),
+ mode: modeSchema.optional(),
+ reasoning_effort: reasoningEffortSchema.optional(),
+ })
+ .strict(),
+ })
+ .strict();
+
+const contextPatchSchema = z
+ .object({
+ model_name: modelNameSchema.nullable().optional(),
+ mode: modeSchema.nullable().optional(),
+ reasoning_effort: reasoningEffortSchema.nullable().optional(),
+ })
+ .strict()
+ .refine((value) => Object.keys(value).length > 0);
+
+const tokenUsagePatchSchema = z
+ .object({
+ headerTotal: z.boolean().optional(),
+ inlineMode: inlineModeSchema.optional(),
+ })
+ .strict()
+ .refine((value) => Object.keys(value).length > 0);
+
+export const persistedUserSettingsPatchSchema = z
+ .object({
+ notification: z.object({ enabled: z.boolean() }).strict().optional(),
+ tokenUsage: tokenUsagePatchSchema.optional(),
+ context: contextPatchSchema.optional(),
+ })
+ .strict()
+ .refine((value) => Object.keys(value).length > 0);
+
+export type PersistedUserSettings = z.infer;
+export type PersistedUserSettingsPatch = z.infer<
+ typeof persistedUserSettingsPatchSchema
+>;
+
+const DEFAULT_PERSISTED_USER_SETTINGS: PersistedUserSettings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+};
+
+export function parsePersistedUserSettings(
+ value: unknown,
+): PersistedUserSettings | null {
+ const parsed = persistedUserSettingsSchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+}
+
+export function parsePersistedUserSettingsPatch(
+ value: unknown,
+): PersistedUserSettingsPatch | null {
+ const parsed = persistedUserSettingsPatchSchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+}
+
+/**
+ * Project browser state onto the server's deliberately small allowlist.
+ *
+ * Thread ids/overrides, agent/workspace metadata, browser Notification
+ * permission, and any accidental token-like fields are impossible to include
+ * because this function constructs every accepted property explicitly.
+ */
+export function toPersistedUserSettings(
+ settings: LocalSettings,
+): PersistedUserSettings {
+ const candidate = {
+ notification: { enabled: settings.notification.enabled },
+ tokenUsage: {
+ headerTotal: settings.tokenUsage.headerTotal,
+ inlineMode: settings.tokenUsage.inlineMode,
+ },
+ context: {
+ ...(settings.context.model_name === undefined
+ ? {}
+ : { model_name: settings.context.model_name }),
+ ...(settings.context.mode === undefined
+ ? {}
+ : { mode: settings.context.mode }),
+ ...(settings.context.reasoning_effort === undefined
+ ? {}
+ : { reasoning_effort: settings.context.reasoning_effort }),
+ },
+ };
+ return (
+ parsePersistedUserSettings(candidate) ??
+ structuredClone(DEFAULT_PERSISTED_USER_SETTINGS)
+ );
+}
+
+export function toFullUserSettingsPatch(
+ settings: PersistedUserSettings,
+): PersistedUserSettingsPatch {
+ return {
+ notification: { ...settings.notification },
+ tokenUsage: { ...settings.tokenUsage },
+ context: {
+ model_name: settings.context.model_name ?? null,
+ mode: settings.context.mode ?? null,
+ reasoning_effort: settings.context.reasoning_effort ?? null,
+ },
+ };
+}
+
+export function applyPersistedUserSettingsPatch(
+ settings: PersistedUserSettings,
+ patch: PersistedUserSettingsPatch,
+): PersistedUserSettings {
+ const context = { ...settings.context };
+ const modelName = patch.context?.model_name;
+ if (modelName === null) delete context.model_name;
+ else if (modelName !== undefined) context.model_name = modelName;
+ const mode = patch.context?.mode;
+ if (mode === null) delete context.mode;
+ else if (mode !== undefined) context.mode = mode;
+ const reasoningEffort = patch.context?.reasoning_effort;
+ if (reasoningEffort === null) delete context.reasoning_effort;
+ else if (reasoningEffort !== undefined)
+ context.reasoning_effort = reasoningEffort;
+ return persistedUserSettingsSchema.parse({
+ notification: {
+ ...settings.notification,
+ ...patch.notification,
+ },
+ tokenUsage: {
+ ...settings.tokenUsage,
+ ...patch.tokenUsage,
+ },
+ context,
+ });
+}
+
+export function mergePersistedUserSettingsPatches(
+ first: PersistedUserSettingsPatch | null,
+ second: PersistedUserSettingsPatch,
+): PersistedUserSettingsPatch {
+ return persistedUserSettingsPatchSchema.parse({
+ ...(first ?? {}),
+ ...second,
+ ...((first?.notification ?? second.notification) && {
+ notification: {
+ ...first?.notification,
+ ...second.notification,
+ },
+ }),
+ ...((first?.tokenUsage ?? second.tokenUsage) && {
+ tokenUsage: {
+ ...first?.tokenUsage,
+ ...second.tokenUsage,
+ },
+ }),
+ ...((first?.context ?? second.context) && {
+ context: {
+ ...first?.context,
+ ...second.context,
+ },
+ }),
+ });
+}
+
+export function fromPersistedUserSettings(
+ settings: PersistedUserSettings,
+): LocalSettings {
+ return {
+ notification: { ...settings.notification },
+ tokenUsage: { ...settings.tokenUsage },
+ context: { ...settings.context, mode: settings.context.mode },
+ };
+}
diff --git a/frontend/src/core/settings/store.ts b/frontend/src/core/settings/store.ts
index 86e85aa580c..22ecdff5764 100644
--- a/frontend/src/core/settings/store.ts
+++ b/frontend/src/core/settings/store.ts
@@ -4,10 +4,21 @@ import {
THREAD_MODEL_KEY_PREFIX,
getLocalSettings,
getThreadModelName,
+ safeLocalStorage,
saveLocalSettings,
saveThreadModelName,
type LocalSettings,
} from "./local";
+import {
+ fromPersistedUserSettings,
+ mergePersistedUserSettingsPatches,
+ parsePersistedUserSettings,
+ parsePersistedUserSettingsPatch,
+ toFullUserSettingsPatch,
+ toPersistedUserSettings,
+ type PersistedUserSettings,
+ type PersistedUserSettingsPatch,
+} from "./persistence";
type Listener = () => void;
@@ -17,11 +28,21 @@ export type LocalSettingsSetter = (
) => void;
const listeners = new Set();
+const mutationListeners = new Set<
+ (patch: PersistedUserSettingsPatch) => void
+>();
const threadModelNames = new Map();
+const USER_SETTINGS_CACHE_KEY_PREFIX = "deerflow.user-settings-cache.";
+const LEGACY_SETTINGS_OWNER_KEY = "deerflow.local-settings-owner";
+const LEGACY_SETTINGS_LOCK_NAME = "deerflow.user-settings-legacy-migration";
+const USER_SETTINGS_PENDING_KEY_PREFIX = "deerflow.user-settings-pending.";
let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS;
let baseSettingsLoaded = false;
let storageListenerRegistered = false;
+let baseSettingsMutationVersion = 0;
+let activeBaseSettingsUserId: string | null = null;
+let baseSettingsActivationVersion = 0;
function emitChange() {
for (const listener of listeners) {
@@ -29,6 +50,33 @@ function emitChange() {
}
}
+function emitBaseSettingsMutation(key?: keyof LocalSettings) {
+ baseSettingsMutationVersion += 1;
+ const fullPatch = toFullUserSettingsPatch(
+ toPersistedUserSettings(baseSettings),
+ );
+ const patch: PersistedUserSettingsPatch =
+ key === "notification"
+ ? { notification: fullPatch.notification }
+ : key === "tokenUsage"
+ ? { tokenUsage: fullPatch.tokenUsage }
+ : key === "context"
+ ? { context: fullPatch.context }
+ : fullPatch;
+ if (activeBaseSettingsUserId !== null) {
+ savePendingBaseSettingsPatch(
+ activeBaseSettingsUserId,
+ mergePersistedUserSettingsPatches(
+ getPendingBaseSettingsPatch(activeBaseSettingsUserId),
+ patch,
+ ),
+ );
+ }
+ for (const listener of mutationListeners) {
+ listener(patch);
+ }
+}
+
function ensureBaseSettingsLoaded() {
if (baseSettingsLoaded || typeof window === "undefined") {
return;
@@ -47,6 +95,115 @@ function ensureStorageListenerRegistered() {
storageListenerRegistered = true;
}
+function userSettingsCacheStorageKey(userId: string): string {
+ return `${USER_SETTINGS_CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`;
+}
+
+function readUserSettingsCache(userId: string): PersistedUserSettings | null {
+ const json = safeLocalStorage.getItem(userSettingsCacheStorageKey(userId));
+ if (!json) return null;
+ try {
+ return parsePersistedUserSettings(JSON.parse(json));
+ } catch {
+ return null;
+ }
+}
+
+function saveBaseSettingsCache(settings: LocalSettings): void {
+ if (activeBaseSettingsUserId === null) {
+ saveLocalSettings(settings);
+ return;
+ }
+ safeLocalStorage.setItem(
+ userSettingsCacheStorageKey(activeBaseSettingsUserId),
+ JSON.stringify(toPersistedUserSettings(settings)),
+ );
+}
+
+async function claimLegacySettings(
+ userId: string,
+): Promise {
+ const existingOwner = safeLocalStorage.getItem(LEGACY_SETTINGS_OWNER_KEY);
+ if (existingOwner === userId) {
+ return toPersistedUserSettings(getLocalSettings());
+ }
+ if (existingOwner !== null || typeof navigator === "undefined") return null;
+
+ const lockManager = navigator.locks;
+ if (!lockManager) return null;
+ try {
+ return await lockManager.request(
+ LEGACY_SETTINGS_LOCK_NAME,
+ { mode: "exclusive" },
+ () => {
+ const owner = safeLocalStorage.getItem(LEGACY_SETTINGS_OWNER_KEY);
+ if (owner !== null && owner !== userId) return null;
+ if (
+ owner === null &&
+ !safeLocalStorage.setItem(LEGACY_SETTINGS_OWNER_KEY, userId)
+ ) {
+ return null;
+ }
+ return safeLocalStorage.getItem(LEGACY_SETTINGS_OWNER_KEY) === userId
+ ? toPersistedUserSettings(getLocalSettings())
+ : null;
+ },
+ );
+ } catch {
+ // Web Locks may be unavailable in hardened/embedded browsers. In that
+ // case, defaults are safer than assigning one unscoped value twice.
+ return null;
+ }
+}
+
+/**
+ * Bind the local fallback to one authenticated account for this tab.
+ *
+ * The historical cache was unscoped. The first authenticated account claims
+ * that legacy value; later accounts start from their own cache (or defaults)
+ * until the server handshake completes. This keeps tabs signed into different
+ * accounts from forwarding each other's storage events to their own servers.
+ */
+export async function activateBaseSettingsPersistence(
+ userId: string,
+): Promise<() => void> {
+ ensureBaseSettingsLoaded();
+ ensureStorageListenerRegistered();
+ const activationVersion = ++baseSettingsActivationVersion;
+ const activationMutationVersion = baseSettingsMutationVersion;
+ activeBaseSettingsUserId = userId;
+
+ let persisted = readUserSettingsCache(userId);
+ if (persisted === null) {
+ const claimedLegacy = await claimLegacySettings(userId);
+ persisted =
+ readUserSettingsCache(userId) ??
+ claimedLegacy ??
+ toPersistedUserSettings(DEFAULT_LOCAL_SETTINGS);
+ safeLocalStorage.setItem(
+ userSettingsCacheStorageKey(userId),
+ JSON.stringify(persisted),
+ );
+ }
+
+ if (
+ activeBaseSettingsUserId === userId &&
+ baseSettingsActivationVersion === activationVersion &&
+ baseSettingsMutationVersion === activationMutationVersion
+ ) {
+ baseSettings = fromPersistedUserSettings(persisted);
+ emitChange();
+ }
+ return () => {
+ if (
+ activeBaseSettingsUserId === userId &&
+ baseSettingsActivationVersion === activationVersion
+ ) {
+ activeBaseSettingsUserId = null;
+ }
+ };
+}
+
function mergeSettingsSection(
settings: LocalSettings,
key: K,
@@ -69,25 +226,38 @@ function handleStorage(event: StorageEvent) {
ensureBaseSettingsLoaded();
if (event.key === null) {
+ if (activeBaseSettingsUserId !== null) return;
baseSettings = getLocalSettings();
threadModelNames.clear();
+ emitBaseSettingsMutation();
emitChange();
return;
}
- if (event.key === LOCAL_SETTINGS_KEY) {
- baseSettings = getLocalSettings();
+ if (event.key.startsWith(THREAD_MODEL_KEY_PREFIX)) {
+ const threadId = event.key.slice(THREAD_MODEL_KEY_PREFIX.length);
+ threadModelNames.set(threadId, getThreadModelName(threadId));
emitChange();
return;
}
- if (!event.key.startsWith(THREAD_MODEL_KEY_PREFIX)) {
+ if (activeBaseSettingsUserId !== null) {
+ if (event.key !== userSettingsCacheStorageKey(activeBaseSettingsUserId)) {
+ return;
+ }
+ const persisted = readUserSettingsCache(activeBaseSettingsUserId);
+ if (persisted === null) return;
+ baseSettings = fromPersistedUserSettings(persisted);
+ emitBaseSettingsMutation();
+ emitChange();
return;
}
- const threadId = event.key.slice(THREAD_MODEL_KEY_PREFIX.length);
- threadModelNames.set(threadId, getThreadModelName(threadId));
- emitChange();
+ if (event.key === LOCAL_SETTINGS_KEY) {
+ baseSettings = getLocalSettings();
+ emitBaseSettingsMutation();
+ emitChange();
+ }
}
export function subscribe(listener: Listener): () => void {
@@ -105,6 +275,89 @@ export function getBaseSettingsSnapshot(): LocalSettings {
return baseSettings;
}
+export function getPersistedBaseSettingsSnapshot(): PersistedUserSettings {
+ ensureBaseSettingsLoaded();
+ return toPersistedUserSettings(baseSettings);
+}
+
+export function getBaseSettingsMutationVersion(): number {
+ return baseSettingsMutationVersion;
+}
+
+export function getBaseSettingsMutationBoundary(): {
+ version: number;
+ userId: string | null;
+} {
+ return {
+ version: baseSettingsMutationVersion,
+ userId: activeBaseSettingsUserId,
+ };
+}
+
+export function hydrateBaseSettingsFromServer(
+ settings: PersistedUserSettings,
+ expectedVersion: number,
+): boolean {
+ ensureBaseSettingsLoaded();
+ if (expectedVersion !== baseSettingsMutationVersion) return false;
+ baseSettings = fromPersistedUserSettings(settings);
+ saveBaseSettingsCache(baseSettings);
+ emitChange();
+ return true;
+}
+
+export function subscribeBaseSettingsMutations(
+ listener: (patch: PersistedUserSettingsPatch) => void,
+): () => void {
+ ensureBaseSettingsLoaded();
+ ensureStorageListenerRegistered();
+ mutationListeners.add(listener);
+ return () => mutationListeners.delete(listener);
+}
+
+function pendingPatchStorageKey(userId: string): string {
+ return `${USER_SETTINGS_PENDING_KEY_PREFIX}${encodeURIComponent(userId)}`;
+}
+
+export function getPendingBaseSettingsPatch(
+ userId: string,
+): PersistedUserSettingsPatch | null {
+ const json = safeLocalStorage.getItem(pendingPatchStorageKey(userId));
+ if (!json) return null;
+ try {
+ return parsePersistedUserSettingsPatch(JSON.parse(json));
+ } catch {
+ return null;
+ }
+}
+
+export function savePendingBaseSettingsPatch(
+ userId: string,
+ patch: PersistedUserSettingsPatch | null,
+): void {
+ const key = pendingPatchStorageKey(userId);
+ if (patch === null) {
+ safeLocalStorage.removeItem(key);
+ return;
+ }
+ const validated = parsePersistedUserSettingsPatch(patch);
+ if (validated !== null) {
+ safeLocalStorage.setItem(key, JSON.stringify(validated));
+ }
+}
+
+export function seedPendingBaseSettingsFromCurrent(
+ userId: string,
+): PersistedUserSettingsPatch {
+ const fullPatch = toFullUserSettingsPatch(getPersistedBaseSettingsSnapshot());
+ const pendingPatch = mergePersistedUserSettingsPatches(
+ getPendingBaseSettingsPatch(userId),
+ fullPatch,
+ );
+ savePendingBaseSettingsPatch(userId, pendingPatch);
+ return pendingPatch;
+}
+
export function getThreadModelSnapshot(threadId: string): string | undefined {
ensureBaseSettingsLoaded();
@@ -120,7 +373,8 @@ export const updateLocalSettings: LocalSettingsSetter = (key, value) => {
ensureStorageListenerRegistered();
baseSettings = mergeSettingsSection(baseSettings, key, value);
- saveLocalSettings(baseSettings);
+ saveBaseSettingsCache(baseSettings);
+ emitBaseSettingsMutation(key);
emitChange();
};
@@ -134,7 +388,8 @@ export function updateThreadSettings(
const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
baseSettings = nextBaseSettings;
- saveLocalSettings(baseSettings);
+ saveBaseSettingsCache(baseSettings);
+ emitBaseSettingsMutation(key);
if (
key === "context" &&
diff --git a/frontend/src/core/settings/sync.ts b/frontend/src/core/settings/sync.ts
new file mode 100644
index 00000000000..844be8d15db
--- /dev/null
+++ b/frontend/src/core/settings/sync.ts
@@ -0,0 +1,153 @@
+import type { UserSettingsResponse } from "./api";
+import {
+ applyPersistedUserSettingsPatch,
+ mergePersistedUserSettingsPatches,
+ type PersistedUserSettings,
+ type PersistedUserSettingsPatch,
+} from "./persistence";
+
+export interface UserSettingsTransport {
+ get: () => Promise;
+ initialize: (
+ settings: PersistedUserSettings,
+ ) => Promise;
+ patch: (patch: PersistedUserSettingsPatch) => Promise;
+}
+
+export interface UserSettingsSyncStore {
+ getSettings: () => PersistedUserSettings;
+ getMutationVersion: () => number;
+ getPendingPatch: () => PersistedUserSettingsPatch | null;
+ setPendingPatch: (patch: PersistedUserSettingsPatch | null) => void;
+ hydrate: (
+ settings: PersistedUserSettings,
+ expectedVersion: number,
+ ) => boolean;
+ subscribeMutations: (
+ listener: (patch: PersistedUserSettingsPatch) => void,
+ ) => () => void;
+}
+
+/**
+ * Coordinates one authenticated user's local fallback with server state.
+ *
+ * Initial reads are authoritative, except for local edits made after the read
+ * starts. Those edits are folded over the server snapshot and written through
+ * a serialized queue. PATCH responses never mutate local state, so an older
+ * async response cannot roll back a newer click. Failed writes remain in a
+ * user-scoped local outbox; the next handshake folds that patch over its GET
+ * result before hydration, so reconnect/reload cannot erase the unsynced edit.
+ */
+export class UserSettingsSyncController {
+ private stopped = false;
+ private started = false;
+ private bootstrapped = false;
+ private writeFailed = false;
+ private pendingPatch: PersistedUserSettingsPatch | null = null;
+ private inFlightPatch: PersistedUserSettingsPatch | null = null;
+ private writeTask: Promise | null = null;
+ private unsubscribe: (() => void) | null = null;
+
+ constructor(
+ private readonly store: UserSettingsSyncStore,
+ private readonly transport: UserSettingsTransport,
+ ) {}
+
+ async start(): Promise {
+ if (this.started) return;
+ this.started = true;
+ this.pendingPatch = this.store.getPendingPatch();
+ this.unsubscribe = this.store.subscribeMutations((patch) => {
+ this.pendingPatch = mergePersistedUserSettingsPatches(
+ this.pendingPatch,
+ patch,
+ );
+ this.writeFailed = false;
+ this.persistOutbox();
+ if (this.bootstrapped) this.scheduleWrites();
+ });
+
+ try {
+ const response = await this.transport.get();
+ if (this.stopped) return;
+
+ const baselineResponse =
+ response.settings === null
+ ? await this.transport.initialize(this.store.getSettings())
+ : response;
+ if (this.stopped || baselineResponse.settings === null) return;
+
+ const expectedVersion = this.store.getMutationVersion();
+ const desired = this.pendingPatch
+ ? applyPersistedUserSettingsPatch(
+ baselineResponse.settings,
+ this.pendingPatch,
+ )
+ : baselineResponse.settings;
+ this.store.hydrate(desired, expectedVersion);
+ this.bootstrapped = true;
+ this.scheduleWrites();
+ } catch {
+ // Offline/auth-refresh/validation failures are intentionally non-fatal.
+ // The existing localStorage-backed behavior remains available, and the
+ // next authenticated page load tries the handshake again.
+ }
+ }
+
+ stop(): void {
+ this.stopped = true;
+ this.unsubscribe?.();
+ this.unsubscribe = null;
+ }
+
+ async whenIdle(): Promise {
+ while (this.writeTask) await this.writeTask;
+ }
+
+ private scheduleWrites(): void {
+ if (
+ this.stopped ||
+ !this.bootstrapped ||
+ this.writeFailed ||
+ this.writeTask
+ )
+ return;
+ this.writeTask = this.drainWrites().finally(() => {
+ this.writeTask = null;
+ if (this.pendingPatch) this.scheduleWrites();
+ });
+ }
+
+ private async drainWrites(): Promise {
+ while (!this.stopped && this.pendingPatch) {
+ const patch = this.pendingPatch;
+ this.pendingPatch = null;
+ this.inFlightPatch = patch;
+ this.persistOutbox();
+ try {
+ await this.transport.patch(patch);
+ } catch {
+ this.pendingPatch = mergePersistedUserSettingsPatches(
+ patch,
+ this.pendingPatch ?? {},
+ );
+ this.inFlightPatch = null;
+ this.writeFailed = true;
+ this.persistOutbox();
+ return;
+ }
+ this.inFlightPatch = null;
+ this.persistOutbox();
+ }
+ }
+
+ private persistOutbox(): void {
+ const outbox = this.inFlightPatch
+ ? mergePersistedUserSettingsPatches(
+ this.inFlightPatch,
+ this.pendingPatch ?? {},
+ )
+ : this.pendingPatch;
+ this.store.setPendingPatch(outbox);
+ }
+}
diff --git a/frontend/src/core/settings/user-settings-sync.tsx b/frontend/src/core/settings/user-settings-sync.tsx
new file mode 100644
index 00000000000..c18f3c06c7c
--- /dev/null
+++ b/frontend/src/core/settings/user-settings-sync.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import {
+ fetchUserSettings,
+ initializeUserSettings,
+ patchUserSettings,
+} from "./api";
+import {
+ activateBaseSettingsPersistence,
+ getBaseSettingsMutationBoundary,
+ getBaseSettingsMutationVersion,
+ getPendingBaseSettingsPatch,
+ getPersistedBaseSettingsSnapshot,
+ hydrateBaseSettingsFromServer,
+ savePendingBaseSettingsPatch,
+ seedPendingBaseSettingsFromCurrent,
+ subscribeBaseSettingsMutations,
+} from "./store";
+import { UserSettingsSyncController } from "./sync";
+
+function transportForUser(userId: string) {
+ return {
+ get: () => fetchUserSettings(userId),
+ initialize: (settings: Parameters[1]) =>
+ initializeUserSettings(userId, settings),
+ patch: (patch: Parameters[1]) =>
+ patchUserSettings(userId, patch),
+ };
+}
+
+export function UserSettingsSync({
+ enabled,
+ userId,
+}: {
+ enabled: boolean;
+ userId: string;
+}) {
+ return (
+
+ );
+}
+
+function UserSettingsSyncLifecycle({
+ enabled,
+ userId,
+}: {
+ enabled: boolean;
+ userId: string;
+}) {
+ const [activationBoundary] = useState(getBaseSettingsMutationBoundary);
+
+ useEffect(() => {
+ if (!enabled || !userId) return;
+ let cancelled = false;
+ let controller: UserSettingsSyncController | null = null;
+ let deactivatePersistence: (() => void) | null = null;
+ void activateBaseSettingsPersistence(userId).then((deactivate) => {
+ if (cancelled) {
+ deactivate();
+ return;
+ }
+ const activationPatch =
+ getBaseSettingsMutationVersion() !== activationBoundary.version &&
+ (activationBoundary.userId === null ||
+ activationBoundary.userId === userId)
+ ? seedPendingBaseSettingsFromCurrent(userId)
+ : null;
+ deactivatePersistence = deactivate;
+ const store = {
+ getSettings: getPersistedBaseSettingsSnapshot,
+ getMutationVersion: getBaseSettingsMutationVersion,
+ getPendingPatch: () =>
+ activationPatch ?? getPendingBaseSettingsPatch(userId),
+ setPendingPatch: (
+ patch: Parameters[1],
+ ) => savePendingBaseSettingsPatch(userId, patch),
+ hydrate: hydrateBaseSettingsFromServer,
+ subscribeMutations: subscribeBaseSettingsMutations,
+ };
+ controller = new UserSettingsSyncController(
+ store,
+ transportForUser(userId),
+ );
+ void controller.start();
+ });
+ return () => {
+ cancelled = true;
+ controller?.stop();
+ deactivatePersistence?.();
+ };
+ }, [activationBoundary, enabled, userId]);
+
+ return null;
+}
diff --git a/frontend/tests/unit/app/layout-boundaries.test.ts b/frontend/tests/unit/app/layout-boundaries.test.ts
index 3f6e26f2af6..3e6a3228a4a 100644
--- a/frontend/tests/unit/app/layout-boundaries.test.ts
+++ b/frontend/tests/unit/app/layout-boundaries.test.ts
@@ -42,4 +42,12 @@ describe("layout performance boundaries", () => {
expect(layout).not.toContain("getI18n");
}
});
+
+ it("mounts no user-settings requests in auth-disabled or static mode", () => {
+ const workspaceLayout = source("src/app/workspace/layout.tsx");
+
+ expect(workspaceLayout).toContain(
+ "enabled={!isAuthDisabledMode() && !isStaticWebsiteOnly()}",
+ );
+ });
});
diff --git a/frontend/tests/unit/core/settings/api.test.ts b/frontend/tests/unit/core/settings/api.test.ts
new file mode 100644
index 00000000000..9008b3a6768
--- /dev/null
+++ b/frontend/tests/unit/core/settings/api.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, rs } from "@rstest/core";
+
+rs.mock("@/core/api/fetcher", () => ({
+ fetch: rs.fn(),
+}));
+
+rs.mock("@/core/config", () => ({
+ getBackendBaseURL: () => "",
+}));
+
+import { fetch } from "@/core/api/fetcher";
+import {
+ fetchUserSettings,
+ initializeUserSettings,
+ patchUserSettings,
+} from "@/core/settings/api";
+
+const mockedFetch = rs.mocked(fetch);
+const settings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" as const },
+ context: {},
+};
+
+function response(): Response {
+ return new Response(JSON.stringify({ settings, revision: 1 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ mockedFetch.mockReset();
+ mockedFetch.mockImplementation(async () => response());
+});
+
+describe("user settings API", () => {
+ it("binds reads to the user that mounted the sync controller", async () => {
+ await fetchUserSettings("user-a");
+
+ expect(mockedFetch).toHaveBeenCalledWith("/api/user-preferences", {
+ headers: { "X-DeerFlow-Expected-User-Id": "user-a" },
+ });
+ });
+
+ it("binds initialization and patches to the same expected owner", async () => {
+ await initializeUserSettings("user-a", settings);
+ await patchUserSettings("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+
+ expect(mockedFetch).toHaveBeenNthCalledWith(1, "/api/user-preferences", {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-DeerFlow-Expected-User-Id": "user-a",
+ },
+ body: JSON.stringify({ settings }),
+ });
+ expect(mockedFetch).toHaveBeenNthCalledWith(2, "/api/user-preferences", {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-DeerFlow-Expected-User-Id": "user-a",
+ },
+ body: JSON.stringify({ tokenUsage: { inlineMode: "off" } }),
+ });
+ });
+});
diff --git a/frontend/tests/unit/core/settings/persistence.test.ts b/frontend/tests/unit/core/settings/persistence.test.ts
new file mode 100644
index 00000000000..b6ec71ddc92
--- /dev/null
+++ b/frontend/tests/unit/core/settings/persistence.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from "@rstest/core";
+
+import {
+ parsePersistedUserSettings,
+ parsePersistedUserSettingsPatch,
+ toPersistedUserSettings,
+} from "@/core/settings/persistence";
+
+test("the server projection allowlists base settings and excludes thread/private state", () => {
+ const projected = toPersistedUserSettings({
+ notification: { enabled: true, permission: "granted" },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: {
+ model_name: "model-a",
+ mode: "pro",
+ reasoning_effort: "high",
+ thread_id: "private-thread",
+ agent_name: "private-agent",
+ token: "secret",
+ },
+ } as never);
+
+ expect(projected).toEqual({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: {
+ model_name: "model-a",
+ mode: "pro",
+ reasoning_effort: "high",
+ },
+ });
+});
+
+test("rejects malformed or oversized server/local settings", () => {
+ expect(
+ parsePersistedUserSettings({
+ notification: { enabled: "yes" },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ ).toBeNull();
+
+ expect(
+ parsePersistedUserSettings({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: { model_name: "x".repeat(257) },
+ }),
+ ).toBeNull();
+});
+
+test("rejects empty patches at the same boundary as the Gateway schema", () => {
+ expect(parsePersistedUserSettingsPatch({})).toBeNull();
+ expect(parsePersistedUserSettingsPatch({ context: {} })).toBeNull();
+ expect(parsePersistedUserSettingsPatch({ tokenUsage: {} })).toBeNull();
+});
diff --git a/frontend/tests/unit/core/settings/sync.test.ts b/frontend/tests/unit/core/settings/sync.test.ts
new file mode 100644
index 00000000000..9c47a68b5b1
--- /dev/null
+++ b/frontend/tests/unit/core/settings/sync.test.ts
@@ -0,0 +1,297 @@
+import { expect, rs, test } from "@rstest/core";
+
+import type {
+ PersistedUserSettings,
+ PersistedUserSettingsPatch,
+} from "@/core/settings/persistence";
+import {
+ UserSettingsSyncController,
+ type UserSettingsSyncStore,
+ type UserSettingsTransport,
+} from "@/core/settings/sync";
+
+function settings(modelName = "local-model"): PersistedUserSettings {
+ return {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {
+ model_name: modelName,
+ mode: "thinking",
+ reasoning_effort: "medium",
+ },
+ };
+}
+
+class FakeStore implements UserSettingsSyncStore {
+ current: PersistedUserSettings;
+ version = 0;
+ pendingPatch: PersistedUserSettingsPatch | null = null;
+ hydrateCalls: PersistedUserSettings[] = [];
+ private listeners = new Set<(patch: PersistedUserSettingsPatch) => void>();
+
+ constructor(initial: PersistedUserSettings) {
+ this.current = structuredClone(initial);
+ }
+
+ getSettings = () => structuredClone(this.current);
+ getMutationVersion = () => this.version;
+ getPendingPatch = () => structuredClone(this.pendingPatch);
+ setPendingPatch = (patch: PersistedUserSettingsPatch | null) => {
+ this.pendingPatch = structuredClone(patch);
+ };
+
+ hydrate = (next: PersistedUserSettings, expectedVersion: number) => {
+ if (expectedVersion !== this.version) return false;
+ this.current = structuredClone(next);
+ this.hydrateCalls.push(structuredClone(next));
+ return true;
+ };
+
+ subscribeMutations = (
+ listener: (patch: PersistedUserSettingsPatch) => void,
+ ) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ mutate(patch: PersistedUserSettingsPatch) {
+ this.version += 1;
+ if (patch.context?.model_name !== undefined) {
+ this.current.context.model_name = patch.context.model_name ?? undefined;
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ this.current.tokenUsage.inlineMode = patch.tokenUsage.inlineMode;
+ }
+ for (const listener of this.listeners) listener(patch);
+ }
+}
+
+function transportWithServer(
+ initial: PersistedUserSettings | null,
+): UserSettingsTransport & {
+ initialize: ReturnType;
+ patch: ReturnType;
+} {
+ let server = initial === null ? null : structuredClone(initial);
+ return {
+ get: rs.fn(async () => ({ settings: server, revision: server ? 1 : 0 })),
+ initialize: rs.fn(async (local: PersistedUserSettings) => {
+ server ??= structuredClone(local);
+ return { settings: structuredClone(server), revision: 1 };
+ }),
+ patch: rs.fn(async (patch: PersistedUserSettingsPatch) => {
+ if (!server) throw new Error("server was not initialized");
+ if (patch.context?.model_name !== undefined) {
+ server.context.model_name = patch.context.model_name ?? undefined;
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ server.tokenUsage.inlineMode = patch.tokenUsage.inlineMode;
+ }
+ return { settings: structuredClone(server), revision: 2 };
+ }),
+ };
+}
+
+test("hydrates an authenticated user's existing server settings", async () => {
+ const store = new FakeStore(settings("stale-local"));
+ const transport = transportWithServer(settings("server-model"));
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await controller.start();
+
+ expect(store.current.context.model_name).toBe("server-model");
+ expect(transport.initialize).not.toHaveBeenCalled();
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
+test("migrates valid local base settings only when the server record is absent", async () => {
+ const local = settings("local-only");
+ const store = new FakeStore(local);
+ const transport = transportWithServer(null);
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await controller.start();
+
+ expect(transport.initialize).toHaveBeenCalledTimes(1);
+ expect(transport.initialize).toHaveBeenCalledWith(local);
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
+test("keeps local fallback when hydration fails or the gateway is offline", async () => {
+ const local = settings("offline-local");
+ const store = new FakeStore(local);
+ const transport: UserSettingsTransport = {
+ get: rs.fn(async () => {
+ throw new Error("offline");
+ }),
+ initialize: rs.fn(),
+ patch: rs.fn(),
+ };
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await expect(controller.start()).resolves.toBeUndefined();
+
+ expect(store.current).toEqual(local);
+ expect(store.hydrateCalls).toHaveLength(0);
+ controller.stop();
+});
+
+test("replays a newer local mutation instead of applying a stale hydrate response", async () => {
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ const store = new FakeStore(settings("local-before-load"));
+ const transport = transportWithServer(settings("server-before-load"));
+ transport.get = rs.fn(() => getPromise);
+ const controller = new UserSettingsSyncController(store, transport);
+
+ const starting = controller.start();
+ store.mutate({ context: { model_name: "new-local-model" } });
+ resolveGet({ settings: settings("server-before-load"), revision: 1 });
+ await starting;
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("new-local-model");
+ expect(transport.patch).toHaveBeenCalledWith({
+ context: { model_name: "new-local-model" },
+ });
+ controller.stop();
+});
+
+test("does not let an older PATCH response roll back a newer local edit", async () => {
+ let resolveFirstPatch!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const firstPatch = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveFirstPatch = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ let patchCount = 0;
+ transport.patch = rs.fn(async () => {
+ patchCount += 1;
+ if (patchCount === 1) return firstPatch;
+ return { settings: settings("newest"), revision: 3 };
+ });
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+
+ store.mutate({ context: { model_name: "older-edit" } });
+ store.mutate({ context: { model_name: "newest" } });
+ resolveFirstPatch({ settings: settings("older-edit"), revision: 2 });
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("newest");
+ expect(transport.patch).toHaveBeenCalledTimes(2);
+ expect(transport.patch).toHaveBeenNthCalledWith(2, {
+ context: { model_name: "newest" },
+ });
+ controller.stop();
+});
+
+test("keeps the local edit when a background PATCH fails", async () => {
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ transport.patch = rs.fn(async () => {
+ throw new Error("offline during write");
+ });
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+
+ store.mutate({ context: { model_name: "offline-edit" } });
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("offline-edit");
+ expect(transport.patch).toHaveBeenCalledTimes(1);
+ expect(store.pendingPatch).toEqual({
+ context: { model_name: "offline-edit" },
+ });
+ controller.stop();
+});
+
+test("persists an in-flight write before a reload can interrupt it", async () => {
+ const store = new FakeStore(settings("initial"));
+ let resolvePatch!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const pendingRequest = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolvePatch = resolve;
+ });
+ const transport = transportWithServer(settings("server"));
+ transport.patch = rs.fn(() => pendingRequest);
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+
+ store.mutate({ context: { model_name: "survives-reload" } });
+ await Promise.resolve();
+
+ expect(store.pendingPatch).toEqual({
+ context: { model_name: "survives-reload" },
+ });
+ controller.stop();
+ resolvePatch({ settings: settings("survives-reload"), revision: 2 });
+ await controller.whenIdle();
+});
+
+test("replays a failed write before a later GET can overwrite the local choice", async () => {
+ const firstStore = new FakeStore(settings("initial"));
+ const failingTransport = transportWithServer(settings("server-old"));
+ failingTransport.patch = rs.fn(async () => {
+ throw new Error("offline during write");
+ });
+ const first = new UserSettingsSyncController(firstStore, failingTransport);
+ await first.start();
+ firstStore.mutate({ context: { model_name: "unsynced-local" } });
+ await first.whenIdle();
+ first.stop();
+
+ const reloadedStore = new FakeStore(settings("unsynced-local"));
+ reloadedStore.pendingPatch = structuredClone(firstStore.pendingPatch);
+ const recoveredTransport = transportWithServer(settings("server-old"));
+ const reloaded = new UserSettingsSyncController(
+ reloadedStore,
+ recoveredTransport,
+ );
+ await reloaded.start();
+ await reloaded.whenIdle();
+
+ expect(reloadedStore.current.context.model_name).toBe("unsynced-local");
+ expect(recoveredTransport.patch).toHaveBeenCalledWith({
+ context: { model_name: "unsynced-local" },
+ });
+ expect(reloadedStore.pendingPatch).toBeNull();
+ reloaded.stop();
+});
+
+test("a reload hydrates the value migrated by the previous session", async () => {
+ const transport = transportWithServer(null);
+ const firstStore = new FakeStore(settings("migrated-model"));
+ const first = new UserSettingsSyncController(firstStore, transport);
+ await first.start();
+ first.stop();
+
+ const reloadedStore = new FakeStore(settings("different-local"));
+ const reloaded = new UserSettingsSyncController(reloadedStore, transport);
+ await reloaded.start();
+
+ expect(reloadedStore.current.context.model_name).toBe("migrated-model");
+ expect(transport.initialize).toHaveBeenCalledTimes(1);
+ reloaded.stop();
+});
diff --git a/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx b/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
new file mode 100644
index 00000000000..fa37295b59c
--- /dev/null
+++ b/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
@@ -0,0 +1,424 @@
+import { afterEach, expect, rs, test } from "@rstest/core";
+import { cleanup, render, waitFor } from "@testing-library/react";
+
+rs.mock("@/core/settings/api", () => ({
+ fetchUserSettings: rs.fn(),
+ initializeUserSettings: rs.fn(),
+ patchUserSettings: rs.fn(),
+}));
+
+import {
+ fetchUserSettings,
+ initializeUserSettings,
+ patchUserSettings,
+} from "@/core/settings/api";
+import {
+ activateBaseSettingsPersistence,
+ getPersistedBaseSettingsSnapshot,
+ getPendingBaseSettingsPatch,
+ savePendingBaseSettingsPatch,
+ subscribeBaseSettingsMutations,
+ updateLocalSettings,
+} from "@/core/settings/store";
+import { UserSettingsSync } from "@/core/settings/user-settings-sync";
+
+const mockedFetchUserSettings = rs.mocked(fetchUserSettings);
+const mockedInitializeUserSettings = rs.mocked(initializeUserSettings);
+const mockedPatchUserSettings = rs.mocked(patchUserSettings);
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ mockedFetchUserSettings.mockReset();
+ mockedInitializeUserSettings.mockReset();
+ mockedPatchUserSettings.mockReset();
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: undefined,
+ });
+ rs.restoreAllMocks();
+});
+
+function installSerialWebLocks() {
+ let tail: Promise = Promise.resolve();
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (
+ _name: string,
+ optionsOrCallback: object | (() => unknown),
+ maybeCallback?: () => unknown,
+ ) => {
+ const callback: () => unknown =
+ typeof optionsOrCallback === "function"
+ ? (optionsOrCallback as () => unknown)
+ : maybeCallback!;
+ const result = tail.then(callback);
+ tail = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ },
+ },
+ });
+}
+
+test("auth-disabled mode leaves the existing local-only behavior untouched", async () => {
+ render();
+ await Promise.resolve();
+
+ expect(mockedFetchUserSettings).not.toHaveBeenCalled();
+});
+
+test("an edit made while legacy activation waits is outboxed before server hydration", async () => {
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ },
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: {},
+ },
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ });
+ releaseLock?.();
+
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: {
+ model_name: null,
+ mode: null,
+ reasoning_effort: null,
+ },
+ }),
+ );
+ expect(mockedInitializeUserSettings).not.toHaveBeenCalled();
+ expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe("off");
+});
+
+test("an activation-gap edit survives when browser storage rejects outbox writes", async () => {
+ updateLocalSettings("tokenUsage", { inlineMode: "per_turn" });
+ rs.spyOn(localStorage, "setItem").mockImplementation(() => {
+ throw new DOMException("Blocked", "SecurityError");
+ });
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ },
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: {},
+ },
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
+ releaseLock?.();
+
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: {
+ model_name: null,
+ mode: null,
+ reasoning_effort: null,
+ },
+ }),
+ );
+ expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe("off");
+});
+
+test("a cancelled activation cannot seed another account's snapshot", async () => {
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-b",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ }),
+ );
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ },
+ revision: 1,
+ });
+
+ const view = render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ view.rerender();
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-b"),
+ );
+
+ releaseLock?.();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ });
+});
+
+test("a prior account mutation before activation does not dirty the next account", async () => {
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-b",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ }),
+ );
+ const deactivateAlice = await activateBaseSettingsPersistence("user-a");
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ },
+ revision: 1,
+ });
+
+ function AliceMutationDuringRender() {
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ return null;
+ }
+
+ render(
+ <>
+
+
+ >,
+ );
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-b"),
+ );
+
+ expect(mockedPatchUserSettings).not.toHaveBeenCalled();
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ });
+ expect(getPendingBaseSettingsPatch("user-b")).toBeNull();
+ deactivateAlice();
+});
+
+test("failed-write outboxes are isolated by authenticated user", () => {
+ savePendingBaseSettingsPatch("user-a", {
+ context: { model_name: "unsynced-model" },
+ });
+
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ context: { model_name: "unsynced-model" },
+ });
+ expect(getPendingBaseSettingsPatch("user-b")).toBeNull();
+
+ savePendingBaseSettingsPatch("user-a", null);
+ expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
+});
+
+test("a legacy unscoped cache is claimed by only one authenticated user", async () => {
+ installSerialWebLocks();
+ localStorage.setItem(
+ "deerflow.local-settings",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "alice-model", mode: "thinking" },
+ }),
+ );
+
+ const deactivateAlice = await activateBaseSettingsPersistence("user-a");
+ expect(getPersistedBaseSettingsSnapshot().context.model_name).toBe(
+ "alice-model",
+ );
+ deactivateAlice();
+
+ const deactivateBob = await activateBaseSettingsPersistence("user-b");
+ expect(getPersistedBaseSettingsSnapshot()).toEqual({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ });
+ deactivateBob();
+});
+
+test("concurrent account activation imports the legacy cache at most once", async () => {
+ installSerialWebLocks();
+ localStorage.setItem(
+ "deerflow.local-settings",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "legacy-model" },
+ }),
+ );
+
+ const [deactivateAlice, deactivateBob] = await Promise.all([
+ activateBaseSettingsPersistence("user-a"),
+ activateBaseSettingsPersistence("user-b"),
+ ]);
+ const alice = JSON.parse(
+ localStorage.getItem("deerflow.user-settings-cache.user-a") ?? "null",
+ ) as { context?: { model_name?: string } } | null;
+ const bob = JSON.parse(
+ localStorage.getItem("deerflow.user-settings-cache.user-b") ?? "null",
+ ) as { context?: { model_name?: string } } | null;
+
+ expect(
+ [alice, bob].filter(
+ (settings) => settings?.context?.model_name === "legacy-model",
+ ),
+ ).toHaveLength(1);
+ expect(localStorage.getItem("deerflow.local-settings-owner")).toBe("user-a");
+ deactivateAlice();
+ deactivateBob();
+});
+
+test("without Web Locks an unowned legacy cache is not imported", async () => {
+ localStorage.setItem(
+ "deerflow.local-settings",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "ambiguous-owner" },
+ }),
+ );
+
+ const deactivate = await activateBaseSettingsPersistence("user-a");
+
+ expect(getPersistedBaseSettingsSnapshot().context.model_name).toBeUndefined();
+ expect(localStorage.getItem("deerflow.local-settings-owner")).toBeNull();
+ deactivate();
+});
+
+test("another account's tab cache cannot replace the active user's fallback", async () => {
+ const deactivate = await activateBaseSettingsPersistence("user-b");
+ const before = getPersistedBaseSettingsSnapshot();
+
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "deerflow.user-settings-cache.user-a",
+ newValue: JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "alice-model" },
+ }),
+ storageArea: localStorage,
+ }),
+ );
+
+ expect(getPersistedBaseSettingsSnapshot()).toEqual(before);
+ deactivate();
+});
+
+test("the same account's tab cache still produces a synchronized mutation", async () => {
+ const deactivate = await activateBaseSettingsPersistence("user-a");
+ const listener = rs.fn();
+ const unsubscribe = subscribeBaseSettingsMutations(listener);
+
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "step_debug" },
+ context: {},
+ }),
+ );
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "deerflow.user-settings-cache.user-a",
+ storageArea: localStorage,
+ }),
+ );
+
+ expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe(
+ "step_debug",
+ );
+ expect(listener).toHaveBeenCalledWith({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "step_debug" },
+ context: {
+ model_name: null,
+ mode: null,
+ reasoning_effort: null,
+ },
+ });
+ unsubscribe();
+ deactivate();
+});
From 49a1ad50d03f0111cf7828d97b1c4442d158a36e Mon Sep 17 00:00:00 2001
From: Beautyl0ve <74452755+Beautyl0ve@users.noreply.github.com>
Date: Sun, 23 Aug 2026 10:59:06 +0800
Subject: [PATCH 2/4] fix(settings): harden concurrent preference
synchronization
---
backend/app/gateway/AGENTS.md | 2 +-
.../app/gateway/auth/repositories/sqlite.py | 76 ++-
.../deerflow/persistence/migrations/AGENTS.md | 2 +-
...references.py => 0014_user_preferences.py} | 4 +-
...est_migration_0004_run_ownership_dedupe.py | 2 +-
...ration_0007_scheduled_run_active_dedupe.py | 2 +-
backend/tests/test_persistence_bootstrap.py | 2 +-
.../test_persistence_bootstrap_concurrency.py | 2 +-
.../test_persistence_bootstrap_regression.py | 4 +-
backend/tests/test_user_preferences.py | 131 +++++-
frontend/src/AGENTS.md | 15 +-
frontend/src/core/settings/persistence.ts | 44 ++
frontend/src/core/settings/store.ts | 435 +++++++++++++++---
frontend/src/core/settings/sync.ts | 185 +++++---
.../src/core/settings/user-settings-sync.tsx | 31 +-
.../unit/core/settings/persistence.test.ts | 21 +
.../tests/unit/core/settings/sync.test.ts | 375 ++++++++++++++-
.../settings/user-settings-sync.dom.test.tsx | 237 ++++++++--
18 files changed, 1373 insertions(+), 197 deletions(-)
rename backend/packages/harness/deerflow/persistence/migrations/versions/{0015_user_preferences.py => 0014_user_preferences.py} (92%)
diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md
index 7a63de48e67..9a1bf7819e0 100644
--- a/backend/app/gateway/AGENTS.md
+++ b/backend/app/gateway/AGENTS.md
@@ -45,7 +45,7 @@ reads/searches.
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | `GET /` - report feature availability for frontend UI gating: hot-reloaded `agents_api`, guarded browser capability, and the startup-scoped durable MCP task capability (enabled config plus SQL repository) |
-| **User Preferences** (`/api/user-preferences`) | Authenticated, owner-scoped `GET` / first-writer-wins `PUT` / nested-merge `PATCH` for the browser-safe user-level settings allowlist. The `users.preferences` JSON record is shared across Gateway workers and guarded by a revision CAS; callers cannot select a user id. The optional expected-user header is only a stale-tab guard compared against the authenticated cookie owner. Keep thread/workspace state, browser permission/system state, and credentials outside this contract. |
+| **Preferences** (`/api/user-preferences`) | Owner-scoped settings sync |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py
index 39e3a55d7f0..e347f219928 100644
--- a/backend/app/gateway/auth/repositories/sqlite.py
+++ b/backend/app/gateway/auth/repositories/sqlite.py
@@ -12,12 +12,13 @@
from __future__ import annotations
+import sqlite3
from copy import deepcopy
from datetime import UTC
from uuid import UUID
from sqlalchemy import func, select, update
-from sqlalchemy.exc import IntegrityError
+from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.gateway.auth.models import User
@@ -29,6 +30,8 @@
)
from deerflow.persistence.user.model import UserRow
+_PREFERENCE_WRITE_MAX_ATTEMPTS = 5
+
def _normalize_email(email: str) -> str:
"""Canonicalise an email address for storage and lookup.
@@ -230,35 +233,60 @@ async def initialize_user_preferences(self, user_id: str, settings: dict) -> tup
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):
+ for _attempt in range(_PREFERENCE_WRITE_MAX_ATTEMPTS):
async with self._sf() as session:
- row = (await session.execute(select(UserRow.preferences, UserRow.preferences_revision).where(UserRow.id == user_id))).one_or_none()
- if row is None:
- raise UserNotFoundError(f"User {user_id} no longer exists")
- current, revision = row
- if current is None:
- raise UserPreferencesNotInitializedError(f"Preferences for user {user_id} have not been initialized")
-
- merged = _merge_preferences(current, patch)
- result = await session.execute(
- update(UserRow)
- .where(
- UserRow.id == user_id,
- UserRow.preferences_revision == revision,
- )
- .values(
- preferences=merged,
- preferences_revision=revision + 1,
+ dialect_name = session.get_bind().dialect.name
+ try:
+ row = (await session.execute(select(UserRow.preferences, UserRow.preferences_revision).where(UserRow.id == user_id))).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ current, revision = row
+ if current is None:
+ raise UserPreferencesNotInitializedError(f"Preferences for user {user_id} have not been initialized")
+
+ merged = _merge_preferences(current, patch)
+ result = await session.execute(
+ update(UserRow)
+ .where(
+ UserRow.id == user_id,
+ UserRow.preferences_revision == revision,
+ )
+ .values(
+ preferences=merged,
+ preferences_revision=revision + 1,
+ )
)
- )
- if result.rowcount == 1:
- await session.commit()
- return merged, int(revision) + 1
- await session.rollback()
+ if result.rowcount == 1:
+ await session.commit()
+ return merged, int(revision) + 1
+ await session.rollback()
+ except OperationalError as exc:
+ # In WAL mode, a transaction that SELECTed before another
+ # writer committed cannot upgrade its stale read snapshot.
+ # SQLite reports SQLITE_BUSY_SNAPSHOT immediately; the
+ # connection busy_timeout cannot make that snapshot valid.
+ # Roll back the snapshot and rerun the complete read/merge/
+ # CAS cycle so disjoint client patches are not lost. Other
+ # SQLite failures and every Postgres failure retain their
+ # existing error semantics.
+ if dialect_name != "sqlite" or not _is_sqlite_busy_error(exc):
+ raise
+ await session.rollback()
raise UserPreferencesWriteConflict(f"Concurrent preference updates for user {user_id} did not settle")
+def _is_sqlite_busy_error(exc: OperationalError) -> bool:
+ """Match SQLITE_BUSY and its extended result codes from sqlite3."""
+ error_code = getattr(exc.orig, "sqlite_errorcode", None)
+ if isinstance(error_code, int):
+ return error_code & 0xFF == sqlite3.SQLITE_BUSY
+
+ # Python 3.12's sqlite3 exceptions expose ``sqlite_errorcode``. Keep the
+ # message fallback for compatible DBAPI adapters that omit that attribute.
+ return "database is locked" in str(exc.orig).lower()
+
+
def _merge_preferences(current: dict, patch: dict) -> dict:
"""Deep-merge allowlisted sections; JSON null clears optional fields."""
merged = deepcopy(current)
diff --git a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
index 9e5f5f36a79..f00d757f284 100644
--- a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
+++ b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
@@ -37,6 +37,6 @@ This invokes `alembic revision --autogenerate` against the live ORM models. Revi
- `migrations/versions/0011_mcp_tasks.py` — creates the durable long-running MCP task table and its user/server/remote uniqueness constraint
- `migrations/versions/0012_mcp_task_results.py` — adds bounded result preview/truncation/artifact fields for ordinary task drivers
- `migrations/versions/0013_mcp_task_notifications.py` — adds durable Agent-run notification snapshots, delivery leases, idempotency fields, and the separate bounded-retry attempt counter
-- `migrations/versions/0015_user_preferences.py` — adds nullable user-level preference JSON plus its optimistic concurrency revision; both columns use idempotent add/drop helpers. Its current Draft base remains `0013_mcp_task_notifications`; rebase the `down_revision` onto whichever open `0014` migration lands before this PR is made Ready.
+- `migrations/versions/0014_user_preferences.py` — extends `0013_mcp_task_notifications` with nullable user-level preference JSON plus its optimistic concurrency revision; both columns use idempotent add/drop helpers
- `persistence/bootstrap.py` — `bootstrap_schema(engine, backend=...)`, the three-branch decision + locking
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)
diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0015_user_preferences.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0014_user_preferences.py
similarity index 92%
rename from backend/packages/harness/deerflow/persistence/migrations/versions/0015_user_preferences.py
rename to backend/packages/harness/deerflow/persistence/migrations/versions/0014_user_preferences.py
index f70d5c5334e..b3290ddbb7e 100644
--- a/backend/packages/harness/deerflow/persistence/migrations/versions/0015_user_preferences.py
+++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0014_user_preferences.py
@@ -1,6 +1,6 @@
"""Add durable user-level UI preferences.
-Revision ID: 0015_user_preferences
+Revision ID: 0014_user_preferences
Revises: 0013_mcp_task_notifications
Create Date: 2026-08-23
"""
@@ -11,7 +11,7 @@
import sqlalchemy as sa
-revision: str = "0015_user_preferences"
+revision: str = "0014_user_preferences"
down_revision: str | Sequence[str] | None = "0013_mcp_task_notifications"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
diff --git a/backend/tests/test_migration_0004_run_ownership_dedupe.py b/backend/tests/test_migration_0004_run_ownership_dedupe.py
index 8727307448d..e5bf774e1fb 100644
--- a/backend/tests/test_migration_0004_run_ownership_dedupe.py
+++ b/backend/tests/test_migration_0004_run_ownership_dedupe.py
@@ -157,7 +157,7 @@ async def test_migration_dedupes_duplicate_active_rows_before_unique_index(tmp_p
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
# Bootstrap upgrades through the later revisions after 0004.
- assert version_row[0] == "0015_user_preferences"
+ assert version_row[0] == "0014_user_preferences"
# Sanity: the invariant the index enforces is now true — at most one
# active row per thread.
diff --git a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
index 2472e23ba4e..b3b42059b18 100644
--- a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
+++ b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
@@ -169,7 +169,7 @@ async def test_migration_supersedes_duplicate_active_runs_before_unique_index(tm
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0015_user_preferences"
+ assert version_row[0] == "0014_user_preferences"
# Sanity: the invariant the index enforces now holds — at most one
# active row per task_id.
diff --git a/backend/tests/test_persistence_bootstrap.py b/backend/tests/test_persistence_bootstrap.py
index 01bd8bc4719..2b6977a4f15 100644
--- a/backend/tests/test_persistence_bootstrap.py
+++ b/backend/tests/test_persistence_bootstrap.py
@@ -48,7 +48,7 @@
asyncio_test = pytest.mark.asyncio
-HEAD = "0015_user_preferences"
+HEAD = "0014_user_preferences"
BASELINE = "0001_baseline"
diff --git a/backend/tests/test_persistence_bootstrap_concurrency.py b/backend/tests/test_persistence_bootstrap_concurrency.py
index 786de0cfefa..656dd89d9c3 100644
--- a/backend/tests/test_persistence_bootstrap_concurrency.py
+++ b/backend/tests/test_persistence_bootstrap_concurrency.py
@@ -28,7 +28,7 @@
pytestmark = pytest.mark.asyncio
-HEAD = "0015_user_preferences"
+HEAD = "0014_user_preferences"
def _url(tmp_path: Path) -> str:
diff --git a/backend/tests/test_persistence_bootstrap_regression.py b/backend/tests/test_persistence_bootstrap_regression.py
index b7ffb484940..7254ffe4801 100644
--- a/backend/tests/test_persistence_bootstrap_regression.py
+++ b/backend/tests/test_persistence_bootstrap_regression.py
@@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No
cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
assert "token_usage_by_model" in cols
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0015_user_preferences"
+ assert version_row[0] == "0014_user_preferences"
# And the read path that originally 500'd must now succeed.
sf = get_session_factory()
@@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path
# No duplicate column -- list, not set, to catch dupes.
assert cols.count("token_usage_by_model") == 1
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0015_user_preferences"
+ assert version_row[0] == "0014_user_preferences"
finally:
await close_engine()
diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py
index 706e1d94a26..22ace1bd0de 100644
--- a/backend/tests/test_user_preferences.py
+++ b/backend/tests/test_user_preferences.py
@@ -4,6 +4,7 @@
import asyncio
import importlib.util
+import sqlite3
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock
@@ -19,6 +20,7 @@
from pydantic import ValidationError
from app.gateway.auth.models import User
+from app.gateway.auth.repositories import sqlite as sqlite_repository
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
from app.gateway.routers import user_preferences as user_preferences_router
@@ -66,8 +68,8 @@ async def _create_user(repository: SQLiteUserRepository, email: str) -> User:
def _load_user_preferences_migration() -> ModuleType:
- migration_path = Path(__file__).parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "versions" / "0015_user_preferences.py"
- spec = importlib.util.spec_from_file_location("migration_0015_user_preferences", migration_path)
+ migration_path = Path(__file__).parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "versions" / "0014_user_preferences.py"
+ spec = importlib.util.spec_from_file_location("migration_0014_user_preferences", migration_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@@ -175,6 +177,109 @@ async def test_concurrent_disjoint_updates_do_not_lose_fields(user_repository: S
assert revision == 3
+class _PreferenceReadBarrier:
+ """Hold the first two preference readers on distinct SQLite snapshots."""
+
+ def __init__(self) -> None:
+ self._arrivals = 0
+ self._ready = asyncio.Event()
+ self._writer_committed = asyncio.Event()
+ self.connection_ids: set[int] = set()
+
+ async def wait(self, connection_id: int) -> bool:
+ self.connection_ids.add(connection_id)
+ self._arrivals += 1
+ position = self._arrivals
+ if self._arrivals >= 2:
+ self._ready.set()
+ await self._ready.wait()
+ if position == 2:
+ await self._writer_committed.wait()
+ return position == 1
+
+ def writer_committed(self) -> None:
+ self._writer_committed.set()
+
+
+class _BarrierSession:
+ """AsyncSession proxy that pauses after its first preference SELECT."""
+
+ def __init__(self, session, barrier: _PreferenceReadBarrier) -> None:
+ self._session = session
+ self._barrier = barrier
+ self._first_execute = True
+ self._is_first_writer = False
+
+ async def __aenter__(self):
+ await self._session.__aenter__()
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ return await self._session.__aexit__(exc_type, exc_value, traceback)
+
+ def __getattr__(self, name: str):
+ return getattr(self._session, name)
+
+ async def commit(self) -> None:
+ await self._session.commit()
+ if self._is_first_writer:
+ self._barrier.writer_committed()
+
+ async def execute(self, statement, *args, **kwargs):
+ if not self._first_execute:
+ return await self._session.execute(statement, *args, **kwargs)
+
+ self._first_execute = False
+ # Python's sqlite3 legacy transaction mode does not always begin a
+ # database transaction for SELECT. An explicit BEGIN makes each SELECT
+ # retain a real WAL read snapshot, matching modern transaction mode and
+ # reproducing the read-to-write upgrade race deterministically.
+ await self._session.execute(sa.text("BEGIN"))
+ result = await self._session.execute(statement, *args, **kwargs)
+ connection = await self._session.connection()
+ dbapi_connection = connection.sync_connection.connection.dbapi_connection
+ self._is_first_writer = await self._barrier.wait(id(dbapi_connection))
+ return result
+
+
+@pytest.mark.asyncio
+async def test_concurrent_sqlite_snapshot_busy_retries_without_losing_patch(user_repository: SQLiteUserRepository, monkeypatch: pytest.MonkeyPatch) -> None:
+ user = await _create_user(user_repository, "snapshot-busy@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ barrier = _PreferenceReadBarrier()
+ repository = SQLiteUserRepository(lambda: _BarrierSession(session_factory(), barrier)) # type: ignore[arg-type]
+ busy_error_codes: list[int | None] = []
+ original_is_busy = sqlite_repository._is_sqlite_busy_error
+
+ def capture_busy_error(exc) -> bool:
+ busy_error_codes.append(getattr(exc.orig, "sqlite_errorcode", None))
+ return original_is_busy(exc)
+
+ monkeypatch.setattr(sqlite_repository, "_is_sqlite_busy_error", capture_busy_error)
+
+ await asyncio.gather(
+ repository.merge_user_preferences(
+ str(user.id),
+ {"notification": {"enabled": False}},
+ ),
+ repository.merge_user_preferences(
+ str(user.id),
+ {"tokenUsage": {"inlineMode": "off"}},
+ ),
+ )
+
+ stored, revision = await user_repository.get_user_preferences(str(user.id))
+ assert len(barrier.connection_ids) == 2
+ assert busy_error_codes == [sqlite3.SQLITE_BUSY_SNAPSHOT]
+ assert stored is not None
+ assert stored["notification"]["enabled"] is False
+ assert stored["tokenUsage"]["inlineMode"] == "off"
+ assert revision == 3
+
+
def test_preferences_schema_rejects_unknown_private_or_system_fields() -> None:
with pytest.raises(ValidationError):
UserPreferencesPatchRequest.model_validate(
@@ -320,6 +425,10 @@ def test_user_preferences_migration_is_idempotent_and_reversible(tmp_path: Path)
engine = sa.create_engine(f"sqlite:///{db_path}")
with engine.begin() as connection:
connection.execute(sa.text("CREATE TABLE users (id VARCHAR(36) PRIMARY KEY, email VARCHAR(320) NOT NULL)"))
+ connection.execute(
+ sa.text("INSERT INTO users (id, email) VALUES (:id, :email)"),
+ {"id": "existing-user", "email": "existing@example.com"},
+ )
context = MigrationContext.configure(connection)
with Operations.context(context):
migration.upgrade()
@@ -327,6 +436,24 @@ def test_user_preferences_migration_is_idempotent_and_reversible(tmp_path: Path)
columns = {column["name"] for column in sa.inspect(connection).get_columns("users")}
assert {"preferences", "preferences_revision"} <= columns
+ assert connection.execute(
+ sa.text("SELECT preferences, preferences_revision FROM users WHERE id = 'existing-user'"),
+ ).one() == (None, 0)
+
+ with Operations.context(context):
+ migration.downgrade()
+ migration.downgrade()
+
+ columns = {column["name"] for column in sa.inspect(connection).get_columns("users")}
+ assert columns == {"id", "email"}
+
+ with Operations.context(context):
+ migration.upgrade()
+ migration.upgrade()
+
+ assert connection.execute(
+ sa.text("SELECT preferences, preferences_revision FROM users WHERE id = 'existing-user'"),
+ ).one() == (None, 0)
with Operations.context(context):
migration.downgrade()
diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md
index 5af6cffecc3..a0122b9ae9b 100644
--- a/frontend/src/AGENTS.md
+++ b/frontend/src/AGENTS.md
@@ -24,12 +24,23 @@
Every active-user mutation is captured at the store boundary and enters a
user-scoped, allowlisted local outbox before async activation or network
observers can run. If a setting changes between `UserSettingsSync` render
- and activation, startup also seeds a full current-state patch before
+ and activation, startup diffs the render-time snapshot and seeds only those
+ changed leaves before
hydrating the server response, retaining that patch in memory when browser
storage rejects the durable outbox write.
Failed writes remain in that outbox; the next handshake folds them over the
server read and retries before clearing, so reconnect/reload cannot silently
- erase an unsynchronized local selection. Keep `UserSettingsSync` mounted
+ erase an unsynchronized local selection. Local and cross-tab cache changes
+ enqueue only their changed allowlisted leaves. Each user/leaf has a fixed
+ mutation slot containing an opaque operation id and a separate acknowledgement
+ slot; successful writes advance acknowledgements without deleting mutations,
+ so a later tab write remains pending even when an older request completes.
+ Storage-write failures retain the leaf in memory with the mutation id they
+ observed, allowing a later durable mutation to supersede that fallback. The
+ bootstrap handshake plus every lock-time reread, PATCH, and acknowledgement
+ run under one per-user Web Lock; when Web Locks are unavailable, sync fails
+ closed and local pending work remains untouched. Keep
+ `UserSettingsSync` mounted
before interactive workspace content so its render-time version boundary is
established before settings controls render.
Authenticated fallback caches are also keyed by user. A Web Lock serializes
diff --git a/frontend/src/core/settings/persistence.ts b/frontend/src/core/settings/persistence.ts
index 878ee5f0fd0..ce3b38ff38c 100644
--- a/frontend/src/core/settings/persistence.ts
+++ b/frontend/src/core/settings/persistence.ts
@@ -181,6 +181,50 @@ export function mergePersistedUserSettingsPatches(
});
}
+/**
+ * Return only the allowlisted leaf values that changed between two snapshots.
+ *
+ * In particular, do not promote a one-leaf edit into a whole-section PATCH:
+ * another tab may have a newer value for a sibling leaf in that section.
+ */
+export function diffPersistedUserSettings(
+ previous: PersistedUserSettings,
+ next: PersistedUserSettings,
+): PersistedUserSettingsPatch | null {
+ const patch: {
+ notification?: PersistedUserSettingsPatch["notification"];
+ tokenUsage?: PersistedUserSettingsPatch["tokenUsage"];
+ context?: PersistedUserSettingsPatch["context"];
+ } = {};
+
+ if (previous.notification.enabled !== next.notification.enabled) {
+ patch.notification = { enabled: next.notification.enabled };
+ }
+
+ const tokenUsage: PersistedUserSettingsPatch["tokenUsage"] = {};
+ if (previous.tokenUsage.headerTotal !== next.tokenUsage.headerTotal) {
+ tokenUsage.headerTotal = next.tokenUsage.headerTotal;
+ }
+ if (previous.tokenUsage.inlineMode !== next.tokenUsage.inlineMode) {
+ tokenUsage.inlineMode = next.tokenUsage.inlineMode;
+ }
+ if (Object.keys(tokenUsage).length > 0) patch.tokenUsage = tokenUsage;
+
+ const context: PersistedUserSettingsPatch["context"] = {};
+ if (previous.context.model_name !== next.context.model_name) {
+ context.model_name = next.context.model_name ?? null;
+ }
+ if (previous.context.mode !== next.context.mode) {
+ context.mode = next.context.mode ?? null;
+ }
+ if (previous.context.reasoning_effort !== next.context.reasoning_effort) {
+ context.reasoning_effort = next.context.reasoning_effort ?? null;
+ }
+ if (Object.keys(context).length > 0) patch.context = context;
+
+ return parsePersistedUserSettingsPatch(patch);
+}
+
export function fromPersistedUserSettings(
settings: PersistedUserSettings,
): LocalSettings {
diff --git a/frontend/src/core/settings/store.ts b/frontend/src/core/settings/store.ts
index 22ecdff5764..bd24855a9f7 100644
--- a/frontend/src/core/settings/store.ts
+++ b/frontend/src/core/settings/store.ts
@@ -10,15 +10,21 @@ import {
type LocalSettings,
} from "./local";
import {
+ applyPersistedUserSettingsPatch,
+ diffPersistedUserSettings,
fromPersistedUserSettings,
mergePersistedUserSettingsPatches,
parsePersistedUserSettings,
parsePersistedUserSettingsPatch,
- toFullUserSettingsPatch,
toPersistedUserSettings,
type PersistedUserSettings,
type PersistedUserSettingsPatch,
} from "./persistence";
+import type {
+ UserSettingsMutationPersistence,
+ UserSettingsPatchLeaf,
+ VolatileUserSettingsPatchLeaf,
+} from "./sync";
type Listener = () => void;
@@ -29,12 +35,16 @@ export type LocalSettingsSetter = (
const listeners = new Set();
const mutationListeners = new Set<
- (patch: PersistedUserSettingsPatch) => void
+ (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void
>();
const threadModelNames = new Map();
const USER_SETTINGS_CACHE_KEY_PREFIX = "deerflow.user-settings-cache.";
const LEGACY_SETTINGS_OWNER_KEY = "deerflow.local-settings-owner";
const LEGACY_SETTINGS_LOCK_NAME = "deerflow.user-settings-legacy-migration";
+const USER_SETTINGS_WRITE_LOCK_PREFIX = "deerflow.user-settings-write.";
const USER_SETTINGS_PENDING_KEY_PREFIX = "deerflow.user-settings-pending.";
let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS;
@@ -50,30 +60,18 @@ function emitChange() {
}
}
-function emitBaseSettingsMutation(key?: keyof LocalSettings) {
+function emitBaseSettingsMutation(
+ patch: PersistedUserSettingsPatch | null,
+ persist = true,
+): void {
+ if (patch === null) return;
baseSettingsMutationVersion += 1;
- const fullPatch = toFullUserSettingsPatch(
- toPersistedUserSettings(baseSettings),
- );
- const patch: PersistedUserSettingsPatch =
- key === "notification"
- ? { notification: fullPatch.notification }
- : key === "tokenUsage"
- ? { tokenUsage: fullPatch.tokenUsage }
- : key === "context"
- ? { context: fullPatch.context }
- : fullPatch;
- if (activeBaseSettingsUserId !== null) {
- savePendingBaseSettingsPatch(
- activeBaseSettingsUserId,
- mergePersistedUserSettingsPatches(
- getPendingBaseSettingsPatch(activeBaseSettingsUserId),
- patch,
- ),
- );
- }
+ const persistence =
+ persist && activeBaseSettingsUserId !== null
+ ? appendPendingBaseSettingsPatch(activeBaseSettingsUserId, patch)
+ : { durableLeaves: [], volatileLeaves: [] };
for (const listener of mutationListeners) {
- listener(patch);
+ listener(patch, persistence);
}
}
@@ -156,6 +154,27 @@ async function claimLegacySettings(
}
}
+export async function withBaseSettingsWriteLock(
+ userId: string,
+ task: () => Promise,
+): Promise {
+ if (typeof navigator === "undefined" || !navigator.locks) return false;
+ const encodedUserId = encodeURIComponent(userId);
+ const lockName = `${USER_SETTINGS_WRITE_LOCK_PREFIX}${encodedUserId.length}.${encodedUserId}`;
+ try {
+ return await navigator.locks.request(
+ lockName,
+ { mode: "exclusive" },
+ async () => {
+ await task();
+ return true;
+ },
+ );
+ } catch {
+ return false;
+ }
+}
+
/**
* Bind the local fallback to one authenticated account for this tab.
*
@@ -227,9 +246,16 @@ function handleStorage(event: StorageEvent) {
if (event.key === null) {
if (activeBaseSettingsUserId !== null) return;
+ const previous = toPersistedUserSettings(baseSettings);
baseSettings = getLocalSettings();
threadModelNames.clear();
- emitBaseSettingsMutation();
+ emitBaseSettingsMutation(
+ diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(baseSettings),
+ ),
+ false,
+ );
emitChange();
return;
}
@@ -247,15 +273,26 @@ function handleStorage(event: StorageEvent) {
}
const persisted = readUserSettingsCache(activeBaseSettingsUserId);
if (persisted === null) return;
+ const previous = toPersistedUserSettings(baseSettings);
baseSettings = fromPersistedUserSettings(persisted);
- emitBaseSettingsMutation();
+ emitBaseSettingsMutation(
+ diffPersistedUserSettings(previous, persisted),
+ false,
+ );
emitChange();
return;
}
if (event.key === LOCAL_SETTINGS_KEY) {
+ const previous = toPersistedUserSettings(baseSettings);
baseSettings = getLocalSettings();
- emitBaseSettingsMutation();
+ emitBaseSettingsMutation(
+ diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(baseSettings),
+ ),
+ false,
+ );
emitChange();
}
}
@@ -284,13 +321,22 @@ export function getBaseSettingsMutationVersion(): number {
return baseSettingsMutationVersion;
}
-export function getBaseSettingsMutationBoundary(): {
+export function getBaseSettingsMutationBoundary(userId: string): {
version: number;
userId: string | null;
+ snapshot: PersistedUserSettings;
+ durableLeafOpIds: Record;
} {
return {
version: baseSettingsMutationVersion,
userId: activeBaseSettingsUserId,
+ snapshot: getPersistedBaseSettingsSnapshot(),
+ durableLeafOpIds: Object.fromEntries(
+ PENDING_PATCH_LEAVES.map((leaf) => [
+ leaf,
+ getPendingBaseSettingsLeafOpId(userId, leaf),
+ ]),
+ ) as Record,
};
}
@@ -307,7 +353,10 @@ export function hydrateBaseSettingsFromServer(
}
export function subscribeBaseSettingsMutations(
- listener: (patch: PersistedUserSettingsPatch) => void,
+ listener: (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void,
): () => void {
ensureBaseSettingsLoaded();
ensureStorageListenerRegistered();
@@ -319,43 +368,296 @@ function pendingPatchStorageKey(userId: string): string {
return `${USER_SETTINGS_PENDING_KEY_PREFIX}${encodeURIComponent(userId)}`;
}
-export function getPendingBaseSettingsPatch(
+function pendingPatchLeafPrefix(userId: string): string {
+ const encodedUserId = encodeURIComponent(userId);
+ return `${USER_SETTINGS_PENDING_KEY_PREFIX}leaf.${encodedUserId.length}.${encodedUserId}.`;
+}
+
+const PENDING_PATCH_LEAVES = [
+ "notification.enabled",
+ "tokenUsage.headerTotal",
+ "tokenUsage.inlineMode",
+ "context.model_name",
+ "context.mode",
+ "context.reasoning_effort",
+] as const satisfies readonly UserSettingsPatchLeaf[];
+
+interface PendingPatchEntry {
+ ackKey: string;
+ ackValue: string;
+ patch: PersistedUserSettingsPatch;
+}
+
+export interface PendingBaseSettingsPatchBatch {
+ patch: PersistedUserSettingsPatch;
+ acknowledge: () => boolean;
+}
+
+function pendingPatchLeafStorageKey(
userId: string,
-): PersistedUserSettingsPatch | null {
- const json = safeLocalStorage.getItem(pendingPatchStorageKey(userId));
- if (!json) return null;
+ leaf: UserSettingsPatchLeaf,
+): string {
+ return `${pendingPatchLeafPrefix(userId)}${leaf}`;
+}
+
+function pendingPatchLeafAckStorageKey(
+ userId: string,
+ leaf: UserSettingsPatchLeaf,
+): string {
+ return `${pendingPatchLeafStorageKey(userId, leaf)}.ack`;
+}
+
+function pendingPatchLegacyAckStorageKey(userId: string): string {
+ const encodedUserId = encodeURIComponent(userId);
+ return `${USER_SETTINGS_PENDING_KEY_PREFIX}legacy-ack.${encodedUserId.length}.${encodedUserId}`;
+}
+
+function splitPendingPatchLeaves(
+ patch: PersistedUserSettingsPatch,
+): Array<{ leaf: UserSettingsPatchLeaf; patch: PersistedUserSettingsPatch }> {
+ const leaves: Array<{
+ leaf: UserSettingsPatchLeaf;
+ patch: PersistedUserSettingsPatch;
+ }> = [];
+ if (patch.notification?.enabled !== undefined) {
+ leaves.push({
+ leaf: "notification.enabled",
+ patch: { notification: { enabled: patch.notification.enabled } },
+ });
+ }
+ if (patch.tokenUsage?.headerTotal !== undefined) {
+ leaves.push({
+ leaf: "tokenUsage.headerTotal",
+ patch: { tokenUsage: { headerTotal: patch.tokenUsage.headerTotal } },
+ });
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ leaves.push({
+ leaf: "tokenUsage.inlineMode",
+ patch: { tokenUsage: { inlineMode: patch.tokenUsage.inlineMode } },
+ });
+ }
+ if (patch.context?.model_name !== undefined) {
+ leaves.push({
+ leaf: "context.model_name",
+ patch: { context: { model_name: patch.context.model_name } },
+ });
+ }
+ if (patch.context?.mode !== undefined) {
+ leaves.push({
+ leaf: "context.mode",
+ patch: { context: { mode: patch.context.mode } },
+ });
+ }
+ if (patch.context?.reasoning_effort !== undefined) {
+ leaves.push({
+ leaf: "context.reasoning_effort",
+ patch: { context: { reasoning_effort: patch.context.reasoning_effort } },
+ });
+ }
+ return leaves;
+}
+
+function parsePendingLeafEnvelope(
+ serialized: string,
+ expectedLeaf: UserSettingsPatchLeaf,
+): { opId: string; patch: PersistedUserSettingsPatch } | null {
try {
- return parsePersistedUserSettingsPatch(JSON.parse(json));
+ const value = JSON.parse(serialized) as unknown;
+ if (
+ typeof value !== "object" ||
+ value === null ||
+ Array.isArray(value) ||
+ Object.keys(value).length !== 2 ||
+ !("opId" in value) ||
+ !("patch" in value) ||
+ typeof value.opId !== "string" ||
+ value.opId.length === 0 ||
+ value.opId.length > 128
+ ) {
+ return null;
+ }
+ const patch = parsePersistedUserSettingsPatch(value.patch);
+ if (patch === null) return null;
+ const split = splitPendingPatchLeaves(patch);
+ return split.length === 1 && split[0]?.leaf === expectedLeaf
+ ? { opId: value.opId, patch }
+ : null;
} catch {
return null;
}
}
+function readPendingLeafEnvelope(
+ userId: string,
+ leaf: UserSettingsPatchLeaf,
+): { opId: string; patch: PersistedUserSettingsPatch } | null {
+ const serialized = safeLocalStorage.getItem(
+ pendingPatchLeafStorageKey(userId, leaf),
+ );
+ return serialized === null
+ ? null
+ : parsePendingLeafEnvelope(serialized, leaf);
+}
+
+function readPendingPatchEntries(userId: string): PendingPatchEntry[] {
+ const entries: PendingPatchEntry[] = [];
+ const legacyKey = pendingPatchStorageKey(userId);
+ const keys: Array<{ key: string; leaf: UserSettingsPatchLeaf | null }> = [
+ { key: legacyKey, leaf: null },
+ ...PENDING_PATCH_LEAVES.map((leaf) => ({
+ key: pendingPatchLeafStorageKey(userId, leaf),
+ leaf,
+ })),
+ ];
+ for (const { key, leaf } of keys) {
+ const serialized = safeLocalStorage.getItem(key);
+ if (serialized === null) continue;
+ if (leaf !== null) {
+ const envelope = parsePendingLeafEnvelope(serialized, leaf);
+ const ackKey = pendingPatchLeafAckStorageKey(userId, leaf);
+ if (
+ envelope !== null &&
+ safeLocalStorage.getItem(ackKey) !== envelope.opId
+ ) {
+ entries.push({
+ ackKey,
+ ackValue: envelope.opId,
+ ...envelope,
+ });
+ }
+ continue;
+ }
+ try {
+ const patch = parsePersistedUserSettingsPatch(JSON.parse(serialized));
+ const ackKey = pendingPatchLegacyAckStorageKey(userId);
+ if (patch !== null && safeLocalStorage.getItem(ackKey) !== serialized) {
+ entries.push({
+ ackKey,
+ ackValue: serialized,
+ patch,
+ });
+ }
+ } catch {}
+ }
+ return entries;
+}
+
+export function getPendingBaseSettingsPatchBatch(
+ userId: string,
+): PendingBaseSettingsPatchBatch | null {
+ const entries = readPendingPatchEntries(userId);
+ if (entries.length === 0) return null;
+ const patch = entries.reduce(
+ (merged, entry) => mergePersistedUserSettingsPatches(merged, entry.patch),
+ null,
+ );
+ if (patch === null) return null;
+ return {
+ patch,
+ acknowledge: () => {
+ let acknowledged = true;
+ for (const entry of entries) {
+ if (!safeLocalStorage.setItem(entry.ackKey, entry.ackValue)) {
+ acknowledged = false;
+ }
+ }
+ return acknowledged;
+ },
+ };
+}
+
+export function getPendingBaseSettingsPatch(
+ userId: string,
+): PersistedUserSettingsPatch | null {
+ return getPendingBaseSettingsPatchBatch(userId)?.patch ?? null;
+}
+
+export function getPendingBaseSettingsLeafOpId(
+ userId: string,
+ leaf: UserSettingsPatchLeaf,
+): string | null {
+ return readPendingLeafEnvelope(userId, leaf)?.opId ?? null;
+}
+
+export function appendPendingBaseSettingsPatch(
+ userId: string,
+ patch: PersistedUserSettingsPatch,
+): UserSettingsMutationPersistence {
+ const validated = parsePersistedUserSettingsPatch(patch);
+ const persistence: UserSettingsMutationPersistence = {
+ durableLeaves: [],
+ volatileLeaves: [],
+ };
+ if (validated === null) return persistence;
+ for (const { leaf, patch: leafPatch } of splitPendingPatchLeaves(validated)) {
+ const observedDurableOpId =
+ readPendingLeafEnvelope(userId, leaf)?.opId ?? null;
+ const opId =
+ globalThis.crypto?.randomUUID?.() ??
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+ if (
+ safeLocalStorage.setItem(
+ pendingPatchLeafStorageKey(userId, leaf),
+ JSON.stringify({ opId, patch: leafPatch }),
+ )
+ ) {
+ persistence.durableLeaves.push(leaf);
+ } else {
+ persistence.volatileLeaves.push({
+ leaf,
+ patch: leafPatch,
+ observedDurableOpId,
+ });
+ }
+ }
+ return persistence;
+}
+
export function savePendingBaseSettingsPatch(
userId: string,
patch: PersistedUserSettingsPatch | null,
): void {
- const key = pendingPatchStorageKey(userId);
if (patch === null) {
- safeLocalStorage.removeItem(key);
+ for (const key of [
+ pendingPatchStorageKey(userId),
+ pendingPatchLegacyAckStorageKey(userId),
+ ...PENDING_PATCH_LEAVES.flatMap((leaf) => [
+ pendingPatchLeafStorageKey(userId, leaf),
+ pendingPatchLeafAckStorageKey(userId, leaf),
+ ]),
+ ]) {
+ safeLocalStorage.removeItem(key);
+ }
return;
}
- const validated = parsePersistedUserSettingsPatch(patch);
- if (validated !== null) {
- safeLocalStorage.setItem(key, JSON.stringify(validated));
- }
+ appendPendingBaseSettingsPatch(userId, patch);
}
export function seedPendingBaseSettingsFromCurrent(
userId: string,
-): PersistedUserSettingsPatch {
- const fullPatch = toFullUserSettingsPatch(getPersistedBaseSettingsSnapshot());
- const pendingPatch = mergePersistedUserSettingsPatches(
- getPendingBaseSettingsPatch(userId),
- fullPatch,
+ baseline: PersistedUserSettings,
+ boundaryLeafOpIds: Record,
+): VolatileUserSettingsPatchLeaf[] {
+ const patch = diffPersistedUserSettings(
+ baseline,
+ getPersistedBaseSettingsSnapshot(),
);
- savePendingBaseSettingsPatch(userId, pendingPatch);
- return pendingPatch;
+ if (patch === null) return [];
+ let eligiblePatch: PersistedUserSettingsPatch | null = null;
+ for (const { leaf, patch: leafPatch } of splitPendingPatchLeaves(patch)) {
+ if (
+ getPendingBaseSettingsLeafOpId(userId, leaf) === boundaryLeafOpIds[leaf]
+ ) {
+ eligiblePatch = mergePersistedUserSettingsPatches(
+ eligiblePatch,
+ leafPatch,
+ );
+ }
+ }
+ return eligiblePatch === null
+ ? []
+ : appendPendingBaseSettingsPatch(userId, eligiblePatch).volatileLeaves;
}
export function getThreadModelSnapshot(threadId: string): string | undefined {
@@ -372,9 +674,24 @@ export const updateLocalSettings: LocalSettingsSetter = (key, value) => {
ensureBaseSettingsLoaded();
ensureStorageListenerRegistered();
- baseSettings = mergeSettingsSection(baseSettings, key, value);
+ const previous = toPersistedUserSettings(baseSettings);
+ const locallyMerged = mergeSettingsSection(baseSettings, key, value);
+ const patch = diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(locallyMerged),
+ );
+ const latestPersisted =
+ activeBaseSettingsUserId === null
+ ? null
+ : readUserSettingsCache(activeBaseSettingsUserId);
+ baseSettings =
+ patch !== null && latestPersisted !== null
+ ? fromPersistedUserSettings(
+ applyPersistedUserSettingsPatch(latestPersisted, patch),
+ )
+ : locallyMerged;
+ emitBaseSettingsMutation(patch);
saveBaseSettingsCache(baseSettings);
- emitBaseSettingsMutation(key);
emitChange();
};
@@ -386,10 +703,24 @@ export function updateThreadSettings(
ensureBaseSettingsLoaded();
ensureStorageListenerRegistered();
- const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
- baseSettings = nextBaseSettings;
+ const previous = toPersistedUserSettings(baseSettings);
+ const locallyMerged = mergeSettingsSection(baseSettings, key, value);
+ const patch = diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(locallyMerged),
+ );
+ const latestPersisted =
+ activeBaseSettingsUserId === null
+ ? null
+ : readUserSettingsCache(activeBaseSettingsUserId);
+ baseSettings =
+ patch !== null && latestPersisted !== null
+ ? fromPersistedUserSettings(
+ applyPersistedUserSettingsPatch(latestPersisted, patch),
+ )
+ : locallyMerged;
+ emitBaseSettingsMutation(patch);
saveBaseSettingsCache(baseSettings);
- emitBaseSettingsMutation(key);
if (
key === "context" &&
diff --git a/frontend/src/core/settings/sync.ts b/frontend/src/core/settings/sync.ts
index 844be8d15db..47cd6702cf8 100644
--- a/frontend/src/core/settings/sync.ts
+++ b/frontend/src/core/settings/sync.ts
@@ -14,17 +14,43 @@ export interface UserSettingsTransport {
patch: (patch: PersistedUserSettingsPatch) => Promise;
}
+export type UserSettingsPatchLeaf =
+ | "notification.enabled"
+ | "tokenUsage.headerTotal"
+ | "tokenUsage.inlineMode"
+ | "context.model_name"
+ | "context.mode"
+ | "context.reasoning_effort";
+
+export interface VolatileUserSettingsPatchLeaf {
+ leaf: UserSettingsPatchLeaf;
+ patch: PersistedUserSettingsPatch;
+ observedDurableOpId: string | null;
+}
+
+export interface UserSettingsMutationPersistence {
+ durableLeaves: UserSettingsPatchLeaf[];
+ volatileLeaves: VolatileUserSettingsPatchLeaf[];
+}
+
export interface UserSettingsSyncStore {
getSettings: () => PersistedUserSettings;
getMutationVersion: () => number;
- getPendingPatch: () => PersistedUserSettingsPatch | null;
- setPendingPatch: (patch: PersistedUserSettingsPatch | null) => void;
+ getPendingPatchBatch: () => {
+ patch: PersistedUserSettingsPatch;
+ acknowledge: () => boolean;
+ } | null;
+ getDurableLeafOpId: (leaf: UserSettingsPatchLeaf) => string | null;
+ withWriteLock: (task: () => Promise) => Promise;
hydrate: (
settings: PersistedUserSettings,
expectedVersion: number,
) => boolean;
subscribeMutations: (
- listener: (patch: PersistedUserSettingsPatch) => void,
+ listener: (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void,
) => () => void;
}
@@ -43,50 +69,62 @@ export class UserSettingsSyncController {
private started = false;
private bootstrapped = false;
private writeFailed = false;
- private pendingPatch: PersistedUserSettingsPatch | null = null;
- private inFlightPatch: PersistedUserSettingsPatch | null = null;
+ private readonly volatileLeaves = new Map<
+ UserSettingsPatchLeaf,
+ VolatileUserSettingsPatchLeaf
+ >();
private writeTask: Promise | null = null;
private unsubscribe: (() => void) | null = null;
constructor(
private readonly store: UserSettingsSyncStore,
private readonly transport: UserSettingsTransport,
- ) {}
+ initialVolatileLeaves: VolatileUserSettingsPatchLeaf[] = [],
+ ) {
+ for (const leaf of initialVolatileLeaves) {
+ this.volatileLeaves.set(leaf.leaf, leaf);
+ }
+ }
async start(): Promise {
if (this.started) return;
this.started = true;
- this.pendingPatch = this.store.getPendingPatch();
- this.unsubscribe = this.store.subscribeMutations((patch) => {
- this.pendingPatch = mergePersistedUserSettingsPatches(
- this.pendingPatch,
- patch,
- );
+ this.unsubscribe = this.store.subscribeMutations((_patch, persistence) => {
+ for (const leaf of persistence.durableLeaves) {
+ this.volatileLeaves.delete(leaf);
+ }
+ for (const leaf of persistence.volatileLeaves) {
+ this.volatileLeaves.set(leaf.leaf, leaf);
+ }
this.writeFailed = false;
- this.persistOutbox();
if (this.bootstrapped) this.scheduleWrites();
});
+ const hydrationVersion = this.store.getMutationVersion();
try {
- const response = await this.transport.get();
- if (this.stopped) return;
-
- const baselineResponse =
- response.settings === null
- ? await this.transport.initialize(this.store.getSettings())
- : response;
- if (this.stopped || baselineResponse.settings === null) return;
-
- const expectedVersion = this.store.getMutationVersion();
- const desired = this.pendingPatch
- ? applyPersistedUserSettingsPatch(
- baselineResponse.settings,
- this.pendingPatch,
- )
- : baselineResponse.settings;
- this.store.hydrate(desired, expectedVersion);
- this.bootstrapped = true;
- this.scheduleWrites();
+ const acquired = await this.store.withWriteLock(async () => {
+ const response = await this.transport.get();
+ if (this.stopped) return;
+
+ const baselineResponse =
+ response.settings === null
+ ? await this.transport.initialize(this.store.getSettings())
+ : response;
+ if (this.stopped || baselineResponse.settings === null) return;
+
+ const desiredPatch = this.composePendingPatch(
+ this.store.getPendingPatchBatch(),
+ ).patch;
+ const desired = desiredPatch
+ ? applyPersistedUserSettingsPatch(
+ baselineResponse.settings,
+ desiredPatch,
+ )
+ : baselineResponse.settings;
+ this.store.hydrate(desired, hydrationVersion);
+ this.bootstrapped = true;
+ });
+ if (acquired) this.scheduleWrites();
} catch {
// Offline/auth-refresh/validation failures are intentionally non-fatal.
// The existing localStorage-backed behavior remains available, and the
@@ -104,6 +142,26 @@ export class UserSettingsSyncController {
while (this.writeTask) await this.writeTask;
}
+ private composePendingPatch(
+ durableBatch: ReturnType,
+ ): {
+ patch: PersistedUserSettingsPatch | null;
+ volatileLeaves: VolatileUserSettingsPatchLeaf[];
+ } {
+ let patch = durableBatch?.patch ?? null;
+ const volatileLeaves: VolatileUserSettingsPatchLeaf[] = [];
+ for (const [leaf, volatile] of this.volatileLeaves) {
+ const currentOpId = this.store.getDurableLeafOpId(leaf);
+ if (currentOpId !== volatile.observedDurableOpId) {
+ this.volatileLeaves.delete(leaf);
+ continue;
+ }
+ patch = mergePersistedUserSettingsPatches(patch, volatile.patch);
+ volatileLeaves.push(volatile);
+ }
+ return { patch, volatileLeaves };
+ }
+
private scheduleWrites(): void {
if (
this.stopped ||
@@ -114,40 +172,49 @@ export class UserSettingsSyncController {
return;
this.writeTask = this.drainWrites().finally(() => {
this.writeTask = null;
- if (this.pendingPatch) this.scheduleWrites();
+ if (
+ !this.writeFailed &&
+ (this.volatileLeaves.size > 0 ||
+ this.store.getPendingPatchBatch() !== null)
+ ) {
+ this.scheduleWrites();
+ }
});
}
private async drainWrites(): Promise {
- while (!this.stopped && this.pendingPatch) {
- const patch = this.pendingPatch;
- this.pendingPatch = null;
- this.inFlightPatch = patch;
- this.persistOutbox();
- try {
- await this.transport.patch(patch);
- } catch {
- this.pendingPatch = mergePersistedUserSettingsPatches(
- patch,
- this.pendingPatch ?? {},
- );
- this.inFlightPatch = null;
+ while (!this.stopped) {
+ let attempted = false;
+ let requestFailed = false;
+ let acknowledgeFailed = false;
+ const acquired = await this.store.withWriteLock(async () => {
+ if (this.stopped) return;
+ const durableBatch = this.store.getPendingPatchBatch();
+ const { patch, volatileLeaves } =
+ this.composePendingPatch(durableBatch);
+ if (patch === null) return;
+ attempted = true;
+ try {
+ await this.transport.patch(patch);
+ } catch {
+ requestFailed = true;
+ return;
+ }
+ if (durableBatch !== null && !durableBatch.acknowledge()) {
+ acknowledgeFailed = true;
+ return;
+ }
+ for (const volatile of volatileLeaves) {
+ if (this.volatileLeaves.get(volatile.leaf) === volatile) {
+ this.volatileLeaves.delete(volatile.leaf);
+ }
+ }
+ });
+ if (!acquired || requestFailed || acknowledgeFailed) {
this.writeFailed = true;
- this.persistOutbox();
return;
}
- this.inFlightPatch = null;
- this.persistOutbox();
+ if (!attempted) return;
}
}
-
- private persistOutbox(): void {
- const outbox = this.inFlightPatch
- ? mergePersistedUserSettingsPatches(
- this.inFlightPatch,
- this.pendingPatch ?? {},
- )
- : this.pendingPatch;
- this.store.setPendingPatch(outbox);
- }
}
diff --git a/frontend/src/core/settings/user-settings-sync.tsx b/frontend/src/core/settings/user-settings-sync.tsx
index c18f3c06c7c..acb251541d1 100644
--- a/frontend/src/core/settings/user-settings-sync.tsx
+++ b/frontend/src/core/settings/user-settings-sync.tsx
@@ -11,12 +11,13 @@ import {
activateBaseSettingsPersistence,
getBaseSettingsMutationBoundary,
getBaseSettingsMutationVersion,
- getPendingBaseSettingsPatch,
+ getPendingBaseSettingsLeafOpId,
+ getPendingBaseSettingsPatchBatch,
getPersistedBaseSettingsSnapshot,
hydrateBaseSettingsFromServer,
- savePendingBaseSettingsPatch,
seedPendingBaseSettingsFromCurrent,
subscribeBaseSettingsMutations,
+ withBaseSettingsWriteLock,
} from "./store";
import { UserSettingsSyncController } from "./sync";
@@ -49,7 +50,9 @@ function UserSettingsSyncLifecycle({
enabled: boolean;
userId: string;
}) {
- const [activationBoundary] = useState(getBaseSettingsMutationBoundary);
+ const [activationBoundary] = useState(() =>
+ getBaseSettingsMutationBoundary(userId),
+ );
useEffect(() => {
if (!enabled || !userId) return;
@@ -61,27 +64,33 @@ function UserSettingsSyncLifecycle({
deactivate();
return;
}
- const activationPatch =
+ const activationVolatileLeaves =
getBaseSettingsMutationVersion() !== activationBoundary.version &&
(activationBoundary.userId === null ||
activationBoundary.userId === userId)
- ? seedPendingBaseSettingsFromCurrent(userId)
- : null;
+ ? seedPendingBaseSettingsFromCurrent(
+ userId,
+ activationBoundary.snapshot,
+ activationBoundary.durableLeafOpIds,
+ )
+ : [];
deactivatePersistence = deactivate;
const store = {
getSettings: getPersistedBaseSettingsSnapshot,
getMutationVersion: getBaseSettingsMutationVersion,
- getPendingPatch: () =>
- activationPatch ?? getPendingBaseSettingsPatch(userId),
- setPendingPatch: (
- patch: Parameters[1],
- ) => savePendingBaseSettingsPatch(userId, patch),
+ getPendingPatchBatch: () => getPendingBaseSettingsPatchBatch(userId),
+ getDurableLeafOpId: (
+ leaf: Parameters[1],
+ ) => getPendingBaseSettingsLeafOpId(userId, leaf),
+ withWriteLock: (task: () => Promise) =>
+ withBaseSettingsWriteLock(userId, task),
hydrate: hydrateBaseSettingsFromServer,
subscribeMutations: subscribeBaseSettingsMutations,
};
controller = new UserSettingsSyncController(
store,
transportForUser(userId),
+ activationVolatileLeaves,
);
void controller.start();
});
diff --git a/frontend/tests/unit/core/settings/persistence.test.ts b/frontend/tests/unit/core/settings/persistence.test.ts
index b6ec71ddc92..0ae3e026a74 100644
--- a/frontend/tests/unit/core/settings/persistence.test.ts
+++ b/frontend/tests/unit/core/settings/persistence.test.ts
@@ -1,6 +1,7 @@
import { expect, test } from "@rstest/core";
import {
+ diffPersistedUserSettings,
parsePersistedUserSettings,
parsePersistedUserSettingsPatch,
toPersistedUserSettings,
@@ -54,3 +55,23 @@ test("rejects empty patches at the same boundary as the Gateway schema", () => {
expect(parsePersistedUserSettingsPatch({ context: {} })).toBeNull();
expect(parsePersistedUserSettingsPatch({ tokenUsage: {} })).toBeNull();
});
+
+test("snapshot diffs contain only changed leaves and encode removals as null", () => {
+ expect(
+ diffPersistedUserSettings(
+ {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: { model_name: "old-model", mode: "thinking" },
+ },
+ {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: { mode: "thinking" },
+ },
+ ),
+ ).toEqual({
+ tokenUsage: { inlineMode: "off" },
+ context: { model_name: null },
+ });
+});
diff --git a/frontend/tests/unit/core/settings/sync.test.ts b/frontend/tests/unit/core/settings/sync.test.ts
index 9c47a68b5b1..ebfa9e0befa 100644
--- a/frontend/tests/unit/core/settings/sync.test.ts
+++ b/frontend/tests/unit/core/settings/sync.test.ts
@@ -4,8 +4,11 @@ import type {
PersistedUserSettings,
PersistedUserSettingsPatch,
} from "@/core/settings/persistence";
+import { mergePersistedUserSettingsPatches } from "@/core/settings/persistence";
import {
UserSettingsSyncController,
+ type UserSettingsMutationPersistence,
+ type UserSettingsPatchLeaf,
type UserSettingsSyncStore,
type UserSettingsTransport,
} from "@/core/settings/sync";
@@ -25,9 +28,16 @@ function settings(modelName = "local-model"): PersistedUserSettings {
class FakeStore implements UserSettingsSyncStore {
current: PersistedUserSettings;
version = 0;
- pendingPatch: PersistedUserSettingsPatch | null = null;
+ lockAvailable = true;
hydrateCalls: PersistedUserSettings[] = [];
- private listeners = new Set<(patch: PersistedUserSettingsPatch) => void>();
+ private nextOperationId = 0;
+ private operations = new Map();
+ private listeners = new Set<
+ (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void
+ >();
constructor(initial: PersistedUserSettings) {
this.current = structuredClone(initial);
@@ -35,10 +45,39 @@ class FakeStore implements UserSettingsSyncStore {
getSettings = () => structuredClone(this.current);
getMutationVersion = () => this.version;
- getPendingPatch = () => structuredClone(this.pendingPatch);
- setPendingPatch = (patch: PersistedUserSettingsPatch | null) => {
- this.pendingPatch = structuredClone(patch);
+ getPendingPatchBatch = () => {
+ const entries = [...this.operations.entries()];
+ if (entries.length === 0) return null;
+ const patch = entries.reduce(
+ (merged, [, operation]) =>
+ mergePersistedUserSettingsPatches(merged, operation),
+ null,
+ )!;
+ return {
+ patch: structuredClone(patch),
+ acknowledge: () => {
+ for (const [id] of entries) this.operations.delete(id);
+ return true;
+ },
+ };
};
+ getDurableLeafOpId: (leaf: UserSettingsPatchLeaf) => string | null = (
+ _leaf,
+ ) => null;
+ withWriteLock = async (task: () => Promise) => {
+ if (!this.lockAvailable) return false;
+ await task();
+ return true;
+ };
+
+ get pendingPatch(): PersistedUserSettingsPatch | null {
+ return this.getPendingPatchBatch()?.patch ?? null;
+ }
+
+ set pendingPatch(patch: PersistedUserSettingsPatch | null) {
+ this.operations.clear();
+ if (patch !== null) this.appendPendingPatch(patch);
+ }
hydrate = (next: PersistedUserSettings, expectedVersion: number) => {
if (expectedVersion !== this.version) return false;
@@ -48,13 +87,31 @@ class FakeStore implements UserSettingsSyncStore {
};
subscribeMutations = (
- listener: (patch: PersistedUserSettingsPatch) => void,
+ listener: (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void,
) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
mutate(patch: PersistedUserSettingsPatch) {
+ this.appendPendingPatch(patch);
+ const durableLeaves: UserSettingsPatchLeaf[] = [];
+ if (patch.context?.model_name !== undefined) {
+ durableLeaves.push("context.model_name");
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ durableLeaves.push("tokenUsage.inlineMode");
+ }
+ this.notifyMutation(patch, { durableLeaves, volatileLeaves: [] });
+ }
+
+ notifyMutation(
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) {
this.version += 1;
if (patch.context?.model_name !== undefined) {
this.current.context.model_name = patch.context.model_name ?? undefined;
@@ -62,7 +119,13 @@ class FakeStore implements UserSettingsSyncStore {
if (patch.tokenUsage?.inlineMode !== undefined) {
this.current.tokenUsage.inlineMode = patch.tokenUsage.inlineMode;
}
- for (const listener of this.listeners) listener(patch);
+ for (const listener of this.listeners) {
+ listener(patch, persistence);
+ }
+ }
+
+ appendPendingPatch(patch: PersistedUserSettingsPatch) {
+ this.operations.set(++this.nextOperationId, structuredClone(patch));
}
}
@@ -167,6 +230,127 @@ test("replays a newer local mutation instead of applying a stale hydrate respons
controller.stop();
});
+test("rereads a later durable leaf after GET even without a storage event", async () => {
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ transport.get = rs.fn(() => getPromise);
+ const controller = new UserSettingsSyncController(store, transport);
+ const starting = controller.start();
+
+ store.mutate({ context: { model_name: "durable-p" } });
+ store.appendPendingPatch({ context: { model_name: "durable-q" } });
+ store.current.context.model_name = "durable-q";
+ resolveGet({ settings: settings("server"), revision: 1 });
+ await starting;
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("durable-q");
+ expect(transport.patch).toHaveBeenCalledWith({
+ context: { model_name: "durable-q" },
+ });
+ controller.stop();
+});
+
+test("rejects a stale GET after a newer mutation was already acknowledged", async () => {
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("old-server"));
+ transport.get = rs.fn(() => getPromise);
+ const controller = new UserSettingsSyncController(store, transport);
+ const starting = controller.start();
+
+ store.mutate({ context: { model_name: "newer-local" } });
+ expect(store.getPendingPatchBatch()?.acknowledge()).toBe(true);
+ resolveGet({ settings: settings("old-server"), revision: 1 });
+ await starting;
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("newer-local");
+ expect(store.hydrateCalls).toHaveLength(0);
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
+test("holds the write lock while bootstrap folds a preexisting outbox over GET", async () => {
+ const store = new FakeStore(settings("pending"));
+ store.pendingPatch = { context: { model_name: "pending" } };
+ let lockTail = Promise.resolve();
+ const withSharedLock = async (task: () => Promise) => {
+ const previous = lockTail;
+ let release!: () => void;
+ lockTail = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ await task();
+ return true;
+ } finally {
+ release();
+ }
+ };
+ store.withWriteLock = withSharedLock;
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ let markGetStarted!: () => void;
+ const getStarted = new Promise((resolve) => {
+ markGetStarted = resolve;
+ });
+ const transport = transportWithServer(settings("old-server"));
+ transport.get = rs.fn(() => {
+ markGetStarted();
+ return getPromise;
+ });
+ const controller = new UserSettingsSyncController(store, transport);
+ const starting = controller.start();
+ await getStarted;
+
+ let otherTabAcknowledged = false;
+ const otherTabWrite = withSharedLock(async () => {
+ otherTabAcknowledged = true;
+ expect(store.getPendingPatchBatch()?.acknowledge()).toBe(true);
+ });
+ await Promise.resolve();
+ expect(otherTabAcknowledged).toBe(false);
+
+ resolveGet({ settings: settings("old-server"), revision: 1 });
+ await Promise.all([starting, otherTabWrite]);
+ await controller.whenIdle();
+
+ expect(otherTabAcknowledged).toBe(true);
+ expect(store.hydrateCalls).toEqual([settings("pending")]);
+ expect(store.current.context.model_name).toBe("pending");
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
test("does not let an older PATCH response roll back a newer local edit", async () => {
let resolveFirstPatch!: (value: {
settings: PersistedUserSettings;
@@ -188,6 +372,7 @@ test("does not let an older PATCH response roll back a newer local edit", async
});
const controller = new UserSettingsSyncController(store, transport);
await controller.start();
+ await controller.whenIdle();
store.mutate({ context: { model_name: "older-edit" } });
store.mutate({ context: { model_name: "newest" } });
@@ -222,6 +407,145 @@ test("keeps the local edit when a background PATCH fails", async () => {
controller.stop();
});
+test("fails closed and retains pending work when the write lock is unavailable", async () => {
+ const store = new FakeStore(settings("initial"));
+ store.pendingPatch = { context: { model_name: "pending" } };
+ store.lockAvailable = false;
+ const transport = transportWithServer(settings("server"));
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await controller.start();
+ await controller.whenIdle();
+
+ expect(transport.patch).not.toHaveBeenCalled();
+ expect(store.pendingPatch).toEqual({
+ context: { model_name: "pending" },
+ });
+ controller.stop();
+});
+
+test("serializes an older durable write before a later volatile write", async () => {
+ const tabA = new FakeStore(settings("initial"));
+ const tabB = new FakeStore(settings("initial"));
+ let durableSlot: {
+ opId: string;
+ patch: PersistedUserSettingsPatch;
+ } | null = null;
+ let acknowledgedOpId: string | null = null;
+ let lockTail = Promise.resolve();
+ const withSharedLock = async (task: () => Promise) => {
+ const previous = lockTail;
+ let release!: () => void;
+ lockTail = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ await task();
+ return true;
+ } finally {
+ release();
+ }
+ };
+ for (const store of [tabA, tabB]) {
+ store.getPendingPatchBatch = () => {
+ const captured = durableSlot;
+ if (captured === null || captured.opId === acknowledgedOpId) return null;
+ return {
+ patch: structuredClone(captured.patch),
+ acknowledge: () => {
+ acknowledgedOpId = captured.opId;
+ return true;
+ },
+ };
+ };
+ store.getDurableLeafOpId = (leaf) =>
+ leaf === "context.model_name" ? (durableSlot?.opId ?? null) : null;
+ store.withWriteLock = withSharedLock;
+ }
+
+ let releaseOlderWrite!: () => void;
+ const olderWriteBlocked = new Promise((resolve) => {
+ releaseOlderWrite = resolve;
+ });
+ let markOlderWriteStarted!: () => void;
+ const olderWriteStarted = new Promise((resolve) => {
+ markOlderWriteStarted = resolve;
+ });
+ const server = settings("server");
+ const patchCalls: PersistedUserSettingsPatch[] = [];
+ let inFlight = 0;
+ let maximumInFlight = 0;
+ const patch = async (nextPatch: PersistedUserSettingsPatch) => {
+ patchCalls.push(structuredClone(nextPatch));
+ inFlight += 1;
+ maximumInFlight = Math.max(maximumInFlight, inFlight);
+ try {
+ if (nextPatch.context?.model_name === "durable-q") {
+ markOlderWriteStarted();
+ await olderWriteBlocked;
+ }
+ if (nextPatch.context?.model_name !== undefined) {
+ server.context.model_name = nextPatch.context.model_name ?? undefined;
+ }
+ return {
+ settings: structuredClone(server),
+ revision: patchCalls.length + 1,
+ };
+ } finally {
+ inFlight -= 1;
+ }
+ };
+ const transport = (): UserSettingsTransport => ({
+ get: async () => ({ settings: structuredClone(server), revision: 1 }),
+ initialize: async (local) => ({ settings: local, revision: 1 }),
+ patch,
+ });
+ const controllerA = new UserSettingsSyncController(tabA, transport());
+ const controllerB = new UserSettingsSyncController(tabB, transport());
+ await Promise.all([controllerA.start(), controllerB.start()]);
+ await Promise.all([controllerA.whenIdle(), controllerB.whenIdle()]);
+
+ durableSlot = {
+ opId: "q",
+ patch: { context: { model_name: "durable-q" } },
+ };
+ tabA.notifyMutation(durableSlot.patch, {
+ durableLeaves: ["context.model_name"],
+ volatileLeaves: [],
+ });
+ await olderWriteStarted;
+ tabB.notifyMutation(
+ { context: { model_name: "volatile-p" } },
+ {
+ durableLeaves: [],
+ volatileLeaves: [
+ {
+ leaf: "context.model_name",
+ patch: { context: { model_name: "volatile-p" } },
+ observedDurableOpId: "q",
+ },
+ ],
+ },
+ );
+ await Promise.resolve();
+
+ expect(patchCalls).toEqual([{ context: { model_name: "durable-q" } }]);
+ expect(maximumInFlight).toBe(1);
+
+ releaseOlderWrite();
+ await Promise.all([controllerA.whenIdle(), controllerB.whenIdle()]);
+
+ expect(patchCalls).toEqual([
+ { context: { model_name: "durable-q" } },
+ { context: { model_name: "volatile-p" } },
+ ]);
+ expect(maximumInFlight).toBe(1);
+ expect(server.context.model_name).toBe("volatile-p");
+ controllerA.stop();
+ controllerB.stop();
+});
+
test("persists an in-flight write before a reload can interrupt it", async () => {
const store = new FakeStore(settings("initial"));
let resolvePatch!: (value: {
@@ -250,6 +574,43 @@ test("persists an in-flight write before a reload can interrupt it", async () =>
await controller.whenIdle();
});
+test("acknowledging an in-flight batch preserves a patch appended by another tab", async () => {
+ let resolveFirstPatch!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const firstRequest = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveFirstPatch = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ transport.patch = rs
+ .fn()
+ .mockImplementationOnce(() => firstRequest)
+ .mockResolvedValue({ settings: settings("updated"), revision: 3 });
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+ await controller.whenIdle();
+
+ store.mutate({ context: { model_name: "tab-a" } });
+ await Promise.resolve();
+ store.appendPendingPatch({ context: { model_name: "tab-b-newer" } });
+ resolveFirstPatch({ settings: settings("tab-a"), revision: 2 });
+ await controller.whenIdle();
+
+ expect(transport.patch).toHaveBeenNthCalledWith(1, {
+ context: { model_name: "tab-a" },
+ });
+ expect(transport.patch).toHaveBeenNthCalledWith(2, {
+ context: { model_name: "tab-b-newer" },
+ });
+ expect(store.pendingPatch).toBeNull();
+ controller.stop();
+});
+
test("replays a failed write before a later GET can overwrite the local choice", async () => {
const firstStore = new FakeStore(settings("initial"));
const failingTransport = transportWithServer(settings("server-old"));
diff --git a/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx b/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
index fa37295b59c..367a7939dc3 100644
--- a/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
+++ b/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
@@ -16,6 +16,7 @@ import {
activateBaseSettingsPersistence,
getPersistedBaseSettingsSnapshot,
getPendingBaseSettingsPatch,
+ getPendingBaseSettingsPatchBatch,
savePendingBaseSettingsPatch,
subscribeBaseSettingsMutations,
updateLocalSettings,
@@ -78,6 +79,9 @@ test("an edit made while legacy activation waits is outboxed before server hydra
configurable: true,
value: {
request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
lockRequested = true;
return new Promise((resolve) => {
releaseLock = () => resolve(callback());
@@ -87,17 +91,17 @@ test("an edit made while legacy activation waits is outboxed before server hydra
});
mockedFetchUserSettings.mockResolvedValue({
settings: {
- notification: { enabled: true },
- tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
- context: {},
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "per_turn" },
+ context: { model_name: "server-model" },
},
revision: 1,
});
mockedPatchUserSettings.mockResolvedValue({
settings: {
- notification: { enabled: true },
- tokenUsage: { headerTotal: true, inlineMode: "off" },
- context: {},
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "server-model" },
},
revision: 2,
});
@@ -107,23 +111,21 @@ test("an edit made while legacy activation waits is outboxed before server hydra
updateLocalSettings("tokenUsage", { inlineMode: "off" });
expect(getPendingBaseSettingsPatch("user-a")).toEqual({
- tokenUsage: { headerTotal: true, inlineMode: "off" },
+ tokenUsage: { inlineMode: "off" },
});
releaseLock?.();
await waitFor(() =>
expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
- notification: { enabled: true },
- tokenUsage: { headerTotal: true, inlineMode: "off" },
- context: {
- model_name: null,
- mode: null,
- reasoning_effort: null,
- },
+ tokenUsage: { inlineMode: "off" },
}),
);
expect(mockedInitializeUserSettings).not.toHaveBeenCalled();
- expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe("off");
+ expect(getPersistedBaseSettingsSnapshot()).toEqual({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "server-model" },
+ });
});
test("an activation-gap edit survives when browser storage rejects outbox writes", async () => {
@@ -137,6 +139,9 @@ test("an activation-gap edit survives when browser storage rejects outbox writes
configurable: true,
value: {
request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
lockRequested = true;
return new Promise((resolve) => {
releaseLock = () => resolve(callback());
@@ -169,25 +174,95 @@ test("an activation-gap edit survives when browser storage rejects outbox writes
await waitFor(() =>
expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ }),
+ );
+ expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe("off");
+});
+
+test("activation seeding cannot overwrite a later durable leaf hidden behind a storage event", async () => {
+ updateLocalSettings("tokenUsage", { inlineMode: "per_turn" });
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "per_turn" },
+ });
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ },
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "step_debug" },
+ context: {},
+ },
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
notification: { enabled: true },
tokenUsage: { headerTotal: true, inlineMode: "off" },
- context: {
- model_name: null,
- mode: null,
- reasoning_effort: null,
- },
+ context: {},
}),
);
- expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe("off");
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "deerflow.user-settings-cache.user-a",
+ storageArea: localStorage,
+ }),
+ );
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "step_debug" },
+ });
+ releaseLock?.();
+
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ tokenUsage: { inlineMode: "step_debug" },
+ }),
+ );
+ expect(mockedPatchUserSettings).not.toHaveBeenCalledWith("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
});
test("a cancelled activation cannot seed another account's snapshot", async () => {
+ updateLocalSettings("tokenUsage", { inlineMode: "per_turn" });
let releaseLock: (() => void) | undefined;
let lockRequested = false;
Object.defineProperty(navigator, "locks", {
configurable: true,
value: {
request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
lockRequested = true;
return new Promise((resolve) => {
releaseLock = () => resolve(callback());
@@ -224,11 +299,12 @@ test("a cancelled activation cannot seed another account's snapshot", async () =
await new Promise((resolve) => setTimeout(resolve, 0));
expect(getPendingBaseSettingsPatch("user-a")).toEqual({
- tokenUsage: { headerTotal: true, inlineMode: "off" },
+ tokenUsage: { inlineMode: "off" },
});
});
test("a prior account mutation before activation does not dirty the next account", async () => {
+ installSerialWebLocks();
localStorage.setItem(
"deerflow.user-settings-cache.user-a",
JSON.stringify({
@@ -272,7 +348,7 @@ test("a prior account mutation before activation does not dirty the next account
expect(mockedPatchUserSettings).not.toHaveBeenCalled();
expect(getPendingBaseSettingsPatch("user-a")).toEqual({
- tokenUsage: { headerTotal: true, inlineMode: "off" },
+ tokenUsage: { inlineMode: "off" },
});
expect(getPendingBaseSettingsPatch("user-b")).toBeNull();
deactivateAlice();
@@ -287,11 +363,30 @@ test("failed-write outboxes are isolated by authenticated user", () => {
context: { model_name: "unsynced-model" },
});
expect(getPendingBaseSettingsPatch("user-b")).toBeNull();
+ expect(getPendingBaseSettingsPatch("user-a.ack")).toBeNull();
savePendingBaseSettingsPatch("user-a", null);
expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
});
+test("legacy monolithic outboxes remain readable and clearable", () => {
+ const legacyKey = "deerflow.user-settings-pending.user-a";
+ localStorage.setItem(
+ legacyKey,
+ JSON.stringify({ context: { model_name: "legacy-pending" } }),
+ );
+
+ const batch = getPendingBaseSettingsPatchBatch("user-a");
+ expect(batch?.patch).toEqual({
+ context: { model_name: "legacy-pending" },
+ });
+ expect(batch?.acknowledge()).toBe(true);
+ expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
+
+ savePendingBaseSettingsPatch("user-a", null);
+ expect(localStorage.getItem(legacyKey)).toBeNull();
+});
+
test("a legacy unscoped cache is claimed by only one authenticated user", async () => {
installSerialWebLocks();
localStorage.setItem(
@@ -410,15 +505,97 @@ test("the same account's tab cache still produces a synchronized mutation", asyn
expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe(
"step_debug",
);
- expect(listener).toHaveBeenCalledWith({
- notification: { enabled: true },
- tokenUsage: { headerTotal: true, inlineMode: "step_debug" },
- context: {
- model_name: null,
- mode: null,
- reasoning_effort: null,
+ expect(listener).toHaveBeenCalledWith(
+ {
+ tokenUsage: { inlineMode: "step_debug" },
+ },
+ {
+ durableLeaves: [],
+ volatileLeaves: [],
},
+ );
+ unsubscribe();
+ deactivate();
+});
+
+test("a local leaf edit preserves a newer sibling leaf from another tab", async () => {
+ const deactivate = await activateBaseSettingsPersistence("user-a");
+ const listener = rs.fn();
+ const unsubscribe = subscribeBaseSettingsMutations(listener);
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+
+ expect(
+ JSON.parse(
+ localStorage.getItem("deerflow.user-settings-cache.user-a") ?? "null",
+ ),
+ ).toEqual({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: {},
});
+ expect(listener).toHaveBeenCalledWith(
+ {
+ tokenUsage: { inlineMode: "off" },
+ },
+ {
+ durableLeaves: ["tokenUsage.inlineMode"],
+ volatileLeaves: [],
+ },
+ );
unsubscribe();
deactivate();
});
+
+test("acknowledging one durable batch cannot clear a later tab operation", () => {
+ savePendingBaseSettingsPatch("user-a", {
+ context: { model_name: "tab-a" },
+ });
+ const firstBatch = getPendingBaseSettingsPatchBatch("user-a");
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+
+ expect(firstBatch?.acknowledge()).toBe(true);
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { inlineMode: "off" },
+ });
+});
+
+test("a reload recovers every patch retained by two failed tabs", () => {
+ savePendingBaseSettingsPatch("user-a", {
+ context: { model_name: "offline-tab-a" },
+ });
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+
+ expect(getPendingBaseSettingsPatchBatch("user-a")?.patch).toEqual({
+ context: { model_name: "offline-tab-a" },
+ tokenUsage: { inlineMode: "off" },
+ });
+});
+
+test("a same-leaf overwrite survives acknowledgement of the older batch", () => {
+ rs.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+ const olderBatch = getPendingBaseSettingsPatchBatch("user-a");
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "step_debug" },
+ });
+
+ expect(olderBatch?.acknowledge()).toBe(true);
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { inlineMode: "step_debug" },
+ });
+});
From 02a35f8237ccf8fbbd35f7ff661561c3eb881564 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=97=9C=E9=B5=BC?=
Date: Sun, 23 Aug 2026 23:05:57 +0800
Subject: [PATCH 3/4] fix(settings): recover invalid persisted preferences
---
README.md | 3 +
backend/app/gateway/AGENTS.md | 2 +-
backend/app/gateway/auth/repositories/base.py | 5 ++
.../app/gateway/auth/repositories/sqlite.py | 24 ++++++++
.../app/gateway/routers/user_preferences.py | 38 +++++++++---
backend/tests/test_user_preferences.py | 59 +++++++++++++++++++
frontend/src/AGENTS.md | 8 ++-
frontend/src/core/settings/sync.ts | 12 +++-
.../tests/unit/core/settings/sync.test.ts | 26 ++++++++
9 files changed, 167 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 9d10c2ca1ea..76bb6485395 100644
--- a/README.md
+++ b/README.md
@@ -401,6 +401,9 @@ original local-only settings behavior. Authenticated fallback caches are keyed
by account. On browsers with cross-tab Web Locks, only one authenticated
account can claim the old unscoped cache; without that lock, DeerFlow safely
skips the ambiguous legacy import instead of copying it across accounts.
+If an older or manually edited server record no longer matches the current
+settings schema, DeerFlow clears only that observed revision and rebuilds it
+from the browser's complete current fallback without dropping a pending edit.
#### LangGraph Studio (Optional)
diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md
index 9a1bf7819e0..39c0eee4097 100644
--- a/backend/app/gateway/AGENTS.md
+++ b/backend/app/gateway/AGENTS.md
@@ -45,7 +45,7 @@ reads/searches.
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | `GET /` - report feature availability for frontend UI gating: hot-reloaded `agents_api`, guarded browser capability, and the startup-scoped durable MCP task capability (enabled config plus SQL repository) |
-| **Preferences** (`/api/user-preferences`) | Owner-scoped settings sync |
+| **Preferences** (`/api/user-preferences`) | Owner-scoped settings sync. Persisted values are revalidated on every response; an invalid revision is conditionally cleared so a concurrent repair wins, GET can reimport the browser fallback, and PATCH can explicitly signal the frontend to reinitialize from its complete current local state. |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py
index b5f4324820d..4232b556a21 100644
--- a/backend/app/gateway/auth/repositories/base.py
+++ b/backend/app/gateway/auth/repositories/base.py
@@ -128,3 +128,8 @@ async def initialize_user_preferences(self, user_id: str, settings: dict) -> tup
async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict, int]:
"""Atomically deep-merge a validated partial preference update."""
raise NotImplementedError
+
+ @abstractmethod
+ async def reset_user_preferences_if_revision(self, user_id: str, revision: int) -> tuple[dict | None, int]:
+ """Clear an invalid preference record unless another writer replaced it."""
+ raise NotImplementedError
diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py
index e347f219928..361fc44226b 100644
--- a/backend/app/gateway/auth/repositories/sqlite.py
+++ b/backend/app/gateway/auth/repositories/sqlite.py
@@ -275,6 +275,30 @@ async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict,
raise UserPreferencesWriteConflict(f"Concurrent preference updates for user {user_id} did not settle")
+ async def reset_user_preferences_if_revision(self, user_id: str, revision: int) -> tuple[dict | None, int]:
+ """Clear a corrupt record without erasing a concurrent valid update."""
+ async with self._sf() as session:
+ result = await session.execute(
+ update(UserRow)
+ .where(
+ UserRow.id == user_id,
+ UserRow.preferences_revision == revision,
+ )
+ .values(
+ preferences=None,
+ preferences_revision=revision + 1,
+ )
+ )
+ if result.rowcount == 1:
+ await session.commit()
+ return None, revision + 1
+
+ row = (await session.execute(select(UserRow.preferences, UserRow.preferences_revision).where(UserRow.id == user_id))).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ settings, current_revision = row
+ return deepcopy(settings), int(current_revision)
+
def _is_sqlite_busy_error(exc: OperationalError) -> bool:
"""Match SQLITE_BUSY and its extended result codes from sqlite3."""
diff --git a/backend/app/gateway/routers/user_preferences.py b/backend/app/gateway/routers/user_preferences.py
index b4a24f40e17..50992429b93 100644
--- a/backend/app/gateway/routers/user_preferences.py
+++ b/backend/app/gateway/routers/user_preferences.py
@@ -2,19 +2,22 @@
from __future__ import annotations
+import logging
from typing import Annotated, Literal
from fastapi import APIRouter, HTTPException, Request
-from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
+from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError, model_validator
from app.gateway.auth.repositories.base import (
UserNotFoundError,
UserPreferencesNotInitializedError,
UserPreferencesWriteConflict,
+ UserRepository,
)
from app.gateway.deps import get_current_user_from_request, get_user_repository
router = APIRouter(prefix="/api/user-preferences", tags=["user-preferences"])
+logger = logging.getLogger(__name__)
MAX_USER_PREFERENCES_BYTES = 2048
EXPECTED_USER_ID_HEADER = "X-DeerFlow-Expected-User-Id"
@@ -134,6 +137,17 @@ def _response(settings: dict | None, revision: int) -> UserPreferencesResponse:
return UserPreferencesResponse(settings=validated, revision=revision)
+async def _validated_response(repository: UserRepository, user_id: str, settings: dict | None, revision: int) -> UserPreferencesResponse:
+ """Return a valid record, or clear a corrupt revision for reinitialization."""
+ for _attempt in range(3):
+ try:
+ return _response(settings, revision)
+ except ValidationError:
+ logger.warning("Resetting invalid persisted user preferences for user %s at revision %s", user_id, revision)
+ settings, revision = await repository.reset_user_preferences_if_revision(user_id, revision)
+ return _response(settings, revision)
+
+
def _translate_repository_error(exc: Exception) -> HTTPException:
if isinstance(exc, UserNotFoundError):
return HTTPException(status_code=404, detail="User not found")
@@ -163,11 +177,12 @@ async def _get_guarded_user(request: Request):
async def get_user_preferences(request: Request) -> UserPreferencesResponse:
"""Return preferences for the authenticated user only."""
user = await _get_guarded_user(request)
+ repository = get_user_repository()
try:
- settings, revision = await get_user_repository().get_user_preferences(str(user.id))
+ settings, revision = await repository.get_user_preferences(str(user.id))
+ return await _validated_response(repository, str(user.id), settings, revision)
except Exception as exc:
raise _translate_repository_error(exc) from exc
- return _response(settings, revision)
@router.put("", response_model=UserPreferencesResponse)
@@ -177,14 +192,22 @@ async def initialize_user_preferences(
) -> UserPreferencesResponse:
"""First-writer-wins import of the legacy local base settings."""
user = await _get_guarded_user(request)
+ repository = get_user_repository()
try:
- settings, revision = await get_user_repository().initialize_user_preferences(
+ settings, revision = await repository.initialize_user_preferences(
str(user.id),
body.settings.to_storage_dict(),
)
+ response = await _validated_response(repository, str(user.id), settings, revision)
+ if response.settings is None:
+ settings, revision = await repository.initialize_user_preferences(
+ str(user.id),
+ body.settings.to_storage_dict(),
+ )
+ return _response(settings, revision)
+ return response
except Exception as exc:
raise _translate_repository_error(exc) from exc
- return _response(settings, revision)
@router.patch("", response_model=UserPreferencesResponse)
@@ -194,11 +217,12 @@ async def patch_user_preferences(
) -> UserPreferencesResponse:
"""Deep-merge an allowlisted patch for the authenticated user."""
user = await _get_guarded_user(request)
+ repository = get_user_repository()
try:
- settings, revision = await get_user_repository().merge_user_preferences(
+ settings, revision = await repository.merge_user_preferences(
str(user.id),
body.to_storage_patch(),
)
+ return await _validated_response(repository, str(user.id), settings, revision)
except Exception as exc:
raise _translate_repository_error(exc) from exc
- return _response(settings, revision)
diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py
index 22ace1bd0de..2dcd864c8c7 100644
--- a/backend/tests/test_user_preferences.py
+++ b/backend/tests/test_user_preferences.py
@@ -29,8 +29,10 @@
UserPreferencesInitializeRequest,
UserPreferencesPatchRequest,
get_user_preferences,
+ patch_user_preferences,
)
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+from deerflow.persistence.user.model import UserRow
def _full_preferences(*, model_name: str = "model-a") -> dict:
@@ -67,6 +69,14 @@ async def _create_user(repository: SQLiteUserRepository, email: str) -> User:
return user
+async def _overwrite_stored_preferences(user_id: str, settings: dict) -> None:
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ async with session_factory() as session:
+ await session.execute(sa.update(UserRow).where(UserRow.id == user_id).values(preferences=settings))
+ await session.commit()
+
+
def _load_user_preferences_migration() -> ModuleType:
migration_path = Path(__file__).parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "versions" / "0014_user_preferences.py"
spec = importlib.util.spec_from_file_location("migration_0014_user_preferences", migration_path)
@@ -357,6 +367,55 @@ async def test_route_derives_owner_from_authenticated_request(monkeypatch: pytes
assert response.settings.context.model_name == "owner-model"
+@pytest.mark.asyncio
+async def test_get_resets_an_invalid_persisted_record(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "invalid-get@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+ await _overwrite_stored_preferences(str(user.id), {"context": {}})
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_current_user_from_request", AsyncMock(return_value=user))
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ response = await get_user_preferences(request) # type: ignore[arg-type]
+
+ assert response.settings is None
+ assert response.revision == 2
+ assert await user_repository.get_user_preferences(str(user.id)) == (None, 2)
+
+
+@pytest.mark.asyncio
+async def test_patch_resets_an_invalid_merged_record_for_client_reinitialization(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "invalid-patch@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+ await _overwrite_stored_preferences(str(user.id), {"context": {}})
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_current_user_from_request", AsyncMock(return_value=user))
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ response = await patch_user_preferences(
+ UserPreferencesPatchRequest.model_validate({"context": {"mode": "pro"}}),
+ request, # type: ignore[arg-type]
+ )
+
+ assert response.settings is None
+ assert response.revision == 3
+ assert await user_repository.get_user_preferences(str(user.id)) == (None, 3)
+
+
@pytest.mark.asyncio
async def test_route_rejects_a_stale_tab_after_the_session_owner_changes(monkeypatch: pytest.MonkeyPatch) -> None:
current_user = User(id=uuid4(), email="current@example.com", password_hash="hash")
diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md
index a0122b9ae9b..0c2581aeb32 100644
--- a/frontend/src/AGENTS.md
+++ b/frontend/src/AGENTS.md
@@ -30,7 +30,13 @@
storage rejects the durable outbox write.
Failed writes remain in that outbox; the next handshake folds them over the
server read and retries before clearing, so reconnect/reload cannot silently
- erase an unsynchronized local selection. Local and cross-tab cache changes
+ erase an unsynchronized local selection. A server record that fails current
+ schema validation is also recoverable: the
+ Gateway revision-conditionally clears the corrupt value, and a PATCH response
+ carrying that absent record makes the controller reinitialize from its full
+ current local state before acknowledging the pending mutation. This keeps a
+ repair from silently dropping the edit that discovered the corrupt record.
+ Local and cross-tab cache changes
enqueue only their changed allowlisted leaves. Each user/leaf has a fixed
mutation slot containing an opaque operation id and a separate acknowledgement
slot; successful writes advance acknowledgements without deleting mutations,
diff --git a/frontend/src/core/settings/sync.ts b/frontend/src/core/settings/sync.ts
index 47cd6702cf8..8891c0e3825 100644
--- a/frontend/src/core/settings/sync.ts
+++ b/frontend/src/core/settings/sync.ts
@@ -195,7 +195,17 @@ export class UserSettingsSyncController {
if (patch === null) return;
attempted = true;
try {
- await this.transport.patch(patch);
+ const response = await this.transport.patch(patch);
+ if (response.settings === null) {
+ const recovered = await this.transport.initialize(
+ this.store.getSettings(),
+ );
+ if (recovered.settings === null) {
+ throw new Error(
+ "User settings recovery did not initialize a record",
+ );
+ }
+ }
} catch {
requestFailed = true;
return;
diff --git a/frontend/tests/unit/core/settings/sync.test.ts b/frontend/tests/unit/core/settings/sync.test.ts
index ebfa9e0befa..c8304dd9aae 100644
--- a/frontend/tests/unit/core/settings/sync.test.ts
+++ b/frontend/tests/unit/core/settings/sync.test.ts
@@ -407,6 +407,32 @@ test("keeps the local edit when a background PATCH fails", async () => {
controller.stop();
});
+test("reinitializes a corrupt server record before acknowledging its discovering PATCH", async () => {
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ transport.patch = rs.fn(async () => ({ settings: null, revision: 2 }));
+ transport.initialize = rs.fn(async (local: PersistedUserSettings) => ({
+ settings: structuredClone(local),
+ revision: 3,
+ }));
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+
+ store.mutate({ context: { model_name: "recovered-edit" } });
+ await controller.whenIdle();
+
+ expect(transport.patch).toHaveBeenCalledWith({
+ context: { model_name: "recovered-edit" },
+ });
+ expect(transport.initialize).toHaveBeenCalledWith(
+ expect.objectContaining({
+ context: expect.objectContaining({ model_name: "recovered-edit" }),
+ }),
+ );
+ expect(store.pendingPatch).toBeNull();
+ controller.stop();
+});
+
test("fails closed and retains pending work when the write lock is unavailable", async () => {
const store = new FakeStore(settings("initial"));
store.pendingPatch = { context: { model_name: "pending" } };
From 305cc713b20d31351bcbb101eb59993e8d752477 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=97=9C=E9=B5=BC?=
Date: Tue, 25 Aug 2026 21:36:56 +0800
Subject: [PATCH 4/4] fix(settings): preserve edits during corrupt record
recovery
---
backend/app/gateway/AGENTS.md | 2 +-
backend/app/gateway/auth/repositories/base.py | 4 +--
.../app/gateway/auth/repositories/sqlite.py | 24 +++++++++++++----
backend/tests/test_user_preferences.py | 26 +++++++++++++++++++
frontend/src/AGENTS.md | 7 +++--
frontend/src/core/settings/sync.ts | 6 +++++
.../tests/unit/core/settings/sync.test.ts | 23 +++++++++++-----
7 files changed, 76 insertions(+), 16 deletions(-)
diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md
index c410ef97825..c08fb46a0ba 100644
--- a/backend/app/gateway/AGENTS.md
+++ b/backend/app/gateway/AGENTS.md
@@ -45,7 +45,7 @@ reads/searches.
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | Flags for agents, browser, MCP tasks, and subagent-batch repository/worker availability |
-| **Preferences** (`/api/user-preferences`) | Owner-scoped allowlisted settings sync with revisioned PATCH and invalid-record recovery |
+| **Preferences** (`/api/user-preferences`) | Owner-scoped allowlisted settings sync; corrupt records are CAS-cleared for client recovery |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py
index 4232b556a21..1af12cf4b02 100644
--- a/backend/app/gateway/auth/repositories/base.py
+++ b/backend/app/gateway/auth/repositories/base.py
@@ -125,8 +125,8 @@ async def initialize_user_preferences(self, user_id: str, settings: dict) -> tup
raise NotImplementedError
@abstractmethod
- async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict, int]:
- """Atomically deep-merge a validated partial preference update."""
+ async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict | None, int]:
+ """Atomically merge a patch, or clear a structurally corrupt record."""
raise NotImplementedError
@abstractmethod
diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py
index 361fc44226b..96b315bcfc6 100644
--- a/backend/app/gateway/auth/repositories/sqlite.py
+++ b/backend/app/gateway/auth/repositories/sqlite.py
@@ -231,8 +231,13 @@ async def initialize_user_preferences(self, user_id: str, settings: dict) -> tup
stored, revision = row
return deepcopy(stored), int(revision)
- async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict, int]:
- """Merge with an optimistic revision CAS supported by SQLite/Postgres."""
+ async def merge_user_preferences(self, user_id: str, patch: dict) -> tuple[dict | None, int]:
+ """Merge with an optimistic revision CAS supported by SQLite/Postgres.
+
+ A structurally corrupt JSON value cannot be deep-merged safely. Clear
+ it in the same CAS write so the client receives the normal absent-record
+ recovery signal instead of a server error.
+ """
for _attempt in range(_PREFERENCE_WRITE_MAX_ATTEMPTS):
async with self._sf() as session:
dialect_name = session.get_bind().dialect.name
@@ -311,11 +316,20 @@ def _is_sqlite_busy_error(exc: OperationalError) -> bool:
return "database is locked" in str(exc.orig).lower()
-def _merge_preferences(current: dict, patch: dict) -> dict:
- """Deep-merge allowlisted sections; JSON null clears optional fields."""
+def _merge_preferences(current: object, patch: dict) -> dict | None:
+ """Deep-merge allowlisted sections, or flag an unsafe stored shape."""
+ if not isinstance(current, dict):
+ return None
+
merged = deepcopy(current)
for section, values in patch.items():
- target = merged.setdefault(section, {})
+ if not isinstance(values, dict):
+ return None
+ if section not in merged:
+ merged[section] = {}
+ target = merged[section]
+ if not isinstance(target, dict):
+ return None
for key, value in values.items():
if value is None:
target.pop(key, None)
diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py
index cf5f12c3cdc..a3a8c72e951 100644
--- a/backend/tests/test_user_preferences.py
+++ b/backend/tests/test_user_preferences.py
@@ -416,6 +416,32 @@ async def test_patch_resets_an_invalid_merged_record_for_client_reinitialization
assert await user_repository.get_user_preferences(str(user.id)) == (None, 3)
+@pytest.mark.asyncio
+async def test_patch_resets_structurally_corrupt_record_before_merging(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "structurally-invalid-patch@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+ await _overwrite_stored_preferences(str(user.id), {"context": []})
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_current_user_from_request", AsyncMock(return_value=user))
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ response = await patch_user_preferences(
+ UserPreferencesPatchRequest.model_validate({"context": {"mode": "pro"}}),
+ request, # type: ignore[arg-type]
+ )
+
+ assert response.settings is None
+ assert response.revision == 2
+ assert await user_repository.get_user_preferences(str(user.id)) == (None, 2)
+
+
@pytest.mark.asyncio
async def test_route_rejects_a_stale_tab_after_the_session_owner_changes(monkeypatch: pytest.MonkeyPatch) -> None:
current_user = User(id=uuid4(), email="current@example.com", password_hash="hash")
diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md
index 61168b13b7f..701c08ff64f 100644
--- a/frontend/src/AGENTS.md
+++ b/frontend/src/AGENTS.md
@@ -34,8 +34,11 @@
schema validation is also recoverable: the
Gateway revision-conditionally clears the corrupt value, and a PATCH response
carrying that absent record makes the controller reinitialize from its full
- current local state before acknowledging the pending mutation. This keeps a
- repair from silently dropping the edit that discovered the corrupt record.
+ current local state, then reapply the pending patch before acknowledging it.
+ The replay is required because another device can win the first-writer-wins
+ initialization race with settings that do not include this tab's edit. This
+ keeps a repair from silently dropping the edit that discovered the corrupt
+ record.
Local and cross-tab cache changes
enqueue only their changed allowlisted leaves. Each user/leaf has a fixed
mutation slot containing an opaque operation id and a separate acknowledgement
diff --git a/frontend/src/core/settings/sync.ts b/frontend/src/core/settings/sync.ts
index 8891c0e3825..93e75094a4d 100644
--- a/frontend/src/core/settings/sync.ts
+++ b/frontend/src/core/settings/sync.ts
@@ -205,6 +205,12 @@ export class UserSettingsSyncController {
"User settings recovery did not initialize a record",
);
}
+ const reapplied = await this.transport.patch(patch);
+ if (reapplied.settings === null) {
+ throw new Error(
+ "User settings recovery did not retain the pending patch",
+ );
+ }
}
} catch {
requestFailed = true;
diff --git a/frontend/tests/unit/core/settings/sync.test.ts b/frontend/tests/unit/core/settings/sync.test.ts
index c8304dd9aae..8e954134361 100644
--- a/frontend/tests/unit/core/settings/sync.test.ts
+++ b/frontend/tests/unit/core/settings/sync.test.ts
@@ -407,12 +407,20 @@ test("keeps the local edit when a background PATCH fails", async () => {
controller.stop();
});
-test("reinitializes a corrupt server record before acknowledging its discovering PATCH", async () => {
+test("reapplies a pending patch after corrupt-record recovery before acknowledging it", async () => {
const store = new FakeStore(settings("initial"));
const transport = transportWithServer(settings("server"));
- transport.patch = rs.fn(async () => ({ settings: null, revision: 2 }));
- transport.initialize = rs.fn(async (local: PersistedUserSettings) => ({
- settings: structuredClone(local),
+ let patchAttempt = 0;
+ transport.patch = rs.fn(async () => {
+ patchAttempt += 1;
+ return patchAttempt === 1
+ ? { settings: null, revision: 2 }
+ : { settings: settings("recovered-edit"), revision: 4 };
+ });
+ transport.initialize = rs.fn(async () => ({
+ // Another device won the first-writer-wins initialization race without
+ // this tab's pending mutation.
+ settings: settings("other-device"),
revision: 3,
}));
const controller = new UserSettingsSyncController(store, transport);
@@ -421,9 +429,12 @@ test("reinitializes a corrupt server record before acknowledging its discovering
store.mutate({ context: { model_name: "recovered-edit" } });
await controller.whenIdle();
- expect(transport.patch).toHaveBeenCalledWith({
+ const pendingPatch = {
context: { model_name: "recovered-edit" },
- });
+ };
+ expect(transport.patch).toHaveBeenCalledTimes(2);
+ expect(transport.patch).toHaveBeenNthCalledWith(1, pendingPatch);
+ expect(transport.patch).toHaveBeenNthCalledWith(2, pendingPatch);
expect(transport.initialize).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({ model_name: "recovered-edit" }),