mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-08-27 19:41:48 +00:00
* feat(persistence): CDT-RFC-001 Phase 0 — drift foundations, blob mapper, golden fixtures, fake server harness - Add drift 2.34 / drift_flutter 0.3 / drift_dev; scaffold AppDatabase with per-server open seam (D-08) and the sync_meta table (§6) - ChatBlobMapper (pure Dart) with the §6.1 round-trip invariant: payload/ rawExtra preservation, presence sentinels, unmappable-entry passthrough, deriveChildrenIds, treeIsConsistent - 12 golden chat-blob fixtures authored verbatim from openwebui-src shapes (branched trees, files, web-search sources, code execution, usage, embeds, error/annotation/arena/merged, legacy no-history, timestamp ties) - FakeOpenWebUiServer test harness mirroring vendored list/create/get/update/ delete semantics incl. the update route's shallow top-level merge and output→content re-derivation (serialize_output port) - 137 new tests; full suite 1577 passing; analyze clean for all new files - RFC amended to rev 3: §3.iii is a shallow top-level merge, not blob replace; pushes must always send the complete reconstructed blob * feat(persistence): CDT-RFC-001 Phase 1 — read path, DB as source of truth Schema/DAOs/manager: - chats/messages/folders/outbox tables (schema v2) with hand-rolled DESC and partial indexes (§10) and FK cascade; ChatsDao/MessagesDao/FoldersDao/ SyncMetaDao; per-server DatabaseManager + scoped provider (D-04) - conversation_assembler: MessageRowData.payload -> ChatMessage / Conversation Sync engine (pull only this phase): - chat_locks per-chat async mutex; pull_sync watermark delta (§7.1: 5s overlap, pool of 4, one transaction per chat merge, watermark advances only on full success); SyncApiClient seam over api_service / FakeOpenWebUiServer - sync_triggers (§7.6): once-only start gate, auth/connectivity edges, foreground + 5-min periodic timer with offline skip, single debounced funnel - sync_engine orchestrator; §9.3 legacy Hive purge gated on first fully- successful pull, idempotent via hive_cache_purged flag Provider inversion (read path): - conversation list renders from ChatsDao.watchChatList() (no message bodies); chat page from MessagesDao.watchForChat(); streaming overlay untouched - D-07 stream-completion + app-pause echo into DB under the chat lock - all Hive local_conversations/local_folders reads removed Tests: DAO/manager/pull/triggers suites; offline cold-start acceptance; new D-07 notifier-level persistence test; sync_triggers timing fixes (fakeAsync timer flush + redirect materialization). Full suite 1670 green, analyze clean. * feat(persistence): CDT-RFC-001 Phase 2 foundation — outbox, ID remap, push, migrator (dormant) Data-layer write path, built and tested in isolation; not yet wired into production (task_queue.dart remains the live path until the cutover commit). - OutboxDao: transactional enqueue (no inner txn), per-chat-FIFO claim respecting nextAttemptAt + parked predecessors, coalescing collapse (updateChat→newest, create+updates→create, delete annihilates), N=5 requestCompletion parking, stranded-inFlight recovery (§7.2) - OutboxDrainer: pool-of-2, seq order per chat, backoff 2s→5min full jitter (injectable clock+jitter), offline-deferred reschedule (no attempt burn) - IdRemapper: single-tx local:<uuid>→serverId rewrite of chats/messages/ outbox; content-hash recorded on createChat op (§7.3) - pull_sync crash-heal: pendingCreateForHash matches a pulled chat to its pending createChat op and remaps instead of duplicating - push_sync: createChat/updateChat/deleteChat reconstruct the FULL blob via rowsToBlob (§3.iii), serverUpdatedAt/dirty-clear rule; SyncApiClient extended (fake server mirrors shallow-merge + toggle semantics) - ChatsDao *WithOutbox mutation methods (row + op in one tx, caller holds lock) - OutboxTaskQueueMigrator: idempotent, flag-gated, content-hash dedupe, abort-without-flag on failure (§9.2) Full suite 1767 green, analyze clean. Cutover (drainer/migrator invocation, send-path + mutation + remapEvents wiring, task_queue retirement) follows. * feat(persistence): CDT-RFC-001 Phase 2 cutover — outbox is the live write path Activates the dormant outbox in production and retires the legacy Hive task queue. Phase 2 acceptance (§11) now met. Activation: - ChatRequestCompletionRunner (features/chat) implements the core RequestCompletionRunner seam, re-entering the EXISTING streaming pipeline (runQueuedCompletion) — no second streaming impl; guards: defer when a live stream owns the chat, no-op when the turn already completed; shared assistantMessageId guarantees one assistant row per turn (R8) - Provider layer: idRemapper/pushSync/outboxDrainer/requestCompletionRunner + migrator, all reusing the engine's chatLocks/folderLocks/clock/remapper - SyncEngine._runOnce: after pull, run migrateIfNeeded() once then drain(); single shared OutboxDrainer instance (drain + drainNow) — fixes a concurrent-instance resetInFlightToPending double-send (review high finding) - drainNow() on connectivity-regained; remapRouteSyncProvider swaps the open chat/folder id in place on remap (no nav, no stale-route window) Site swaps (15) + retirement: - send/retry/folder-mutation/peripheral sites → outbox *WithOutbox methods (row + op in one tx under ChatLocks); optimistic UI preserved - media uploads folded into media_upload_controller.dart (not an outbox op) - deleted task_queue.dart / task_worker.dart / outbound_task.dart Tradeoff (documented for Phase 3): queued completions use Option A — a drained background completion makes that chat active. Option B (headless UI-free streaming sink) deferred to Phase 3. §11 acceptance tests: offline-compose→force-quit→reconnect→create+send+remap; queued-Hive-task migration→drain; crash-between-create-and-remap heals without duplicate. Full suite 1776 green, analyze clean. * feat(persistence): CDT-RFC-001 Phase 3 — three-way merge, deletion reconcile, folder LWW Conflict merge (§7.4) — replaces the Phase-2 fast-forward-only stub: - chat_merger.dart pure three-way: union messages by id (local-dirty wins, remote-deleted clean drops, dirty kept), childrenIds always derived from parentId, currentId local-if-dirty, envelope LWW, rawExtra server-wholesale - idempotent (B unchanged on three-way; re-pull hits noRemoteChange); never drops a dirty message; ancestor-reachability re-parents a surviving dirty descendant when a clean ancestor is remotely deleted (no dangling parentId) - mergeServerChat treats a never-synced stub (serverUpdatedAt==null) as a plain server write, not a body-dropping three-way Deletion reconcile (§7.5): - full-ID enumeration diffed against local server-keyed chats; purge ONLY on a confirmed-gone probe; 24h throttle (background) + manual pull-to-refresh - safety valve aborts if >50% of local chats are candidates (token-expiry guard); a watermark-0 pull records last_full_reconcile_at (it already fully enumerated) so the first cycle does no redundant re-enumeration - session-liveness re-check before the first purge: the vendored GET returns 401 for genuine deletes AND for an expired token, so a one-shot authed ping distinguishes them and aborts without purging if the session is dead - wired into SyncEngine._runOnce (background) + reconcileNow() (manual) Folders (§7.6): LWW pull, tombstones, folderUpsert orders before dependent chat ops; folder delete uses delete_contents=false. Review findings (fix phase died on infra) applied inline: orphan re-parenting, session-liveness guard, reconcile wiring + regression tests. Full suite 1823 green, analyze clean. * feat(persistence): CDT-RFC-001 Phase 4 — FTS5 offline search, lazy list, perf budgets FTS5 search: - standalone chat_fts vtable (unicode61, remove_diacritics) over message content + chat titles, created via schema migration; triggers keep it consistent across every message/title write path (insert/update/delete) and an explicit chats-AFTER-DELETE trigger purges on FK-cascade (recursive triggers off) and tombstone-filtered out of results - populated AFTER first full sync (unawaited, idempotent, flag-gated) so it never blocks first interactive render; onUpgrade backfills existing installs - SearchDao: bm25-ranked, grouped one row per chat with snippet; fts_query sanitizer turns arbitrary user text into a safe parameterized MATCH (quotes, operators, empty/unicode/injection all neutralized — no crash) - offline search wired into the existing serverSearchProvider: ranked FTS when offline/reviewer, FTS fallback when server search unavailable - id_remapper now repoints message FTS rows on local:->serverId remap (was a no-op TODO that dropped the index for offline-composed chats — review high) Lazy list + perf: - paged conversation-list reads from the DB (no load-all), Conversations provider API stable - §10 budgets measured on a 1000-chat file-backed fixture: cold-start→list 21-104ms (≤400), append-to-500-msg ~0ms steady (≤10), one list emission per merge tx (zero jank), FTS population off the render path fts-build db-closed teardown race logged at debug (expected on server switch), not error. Full suite 1883 green, analyze clean. * feat(persistence): CDT-RFC-001 Phase 5 — notes as a synced entity Notes sync through the SAME infra as chats (outbox/drainer/id-remap/FTS/locks), extracted behind a SyncEntityAdapter seam now that two real adapters exist. - notes table + NotesDao (field-LWW upsert, *WithOutbox row+op in one tx, tombstone); note_mapper with the §6.1 round-trip invariant (access_grants + unknown keys preserved verbatim via rawExtra) - note_sync: nanosecond notes_pull_watermark in its OWN sync_meta key, never compared to the chat (seconds) watermark — distinct overlap constants (D-11, R-09); list-ordered-updated_at-DESC pull mirroring PullSync - note_conflict (pure): field-level LWW resolving title and data independently; a concurrent data edit spawns a conflict copy (local data preserved, never silently dropped) with no infinite-copy chain (canonical goes clean + base advances) - OutboxKind noteCreate/noteUpdate/noteDelete/notePin with patch-map coalescing; pushNoteUpdate always carries title (router requires it); pin via the dedicated toggle endpoint reconciled against server state - IdRemapper entityKind='note'; FTS extended over note title+text (kind discriminator partitions note rows from chat rows in the shared vtable) - offline notes UI (list + editor) wired into navigation; search returns note hits alongside chat hits SyncEntityAdapter seam (chat + note adapters); SyncEngine drains both kinds and pulls both with separate watermarks. Stabilized after the build agents died mid-run (sustained API outage): fixed test API drift (drainer adapters param, RecordingSyncApiClient note methods), and wrote the RFC-mandated acceptance tests the dead agents never reached — R-09 watermark isolation, D-11 conflict-copy/field-LWW, §6.1 round-trip. Full suite 1899 green, analyze clean. Known follow-ups (low): notes have no deletion-reconcile yet (server-side note deletes not purged locally); notes_dao/note_sync integration unit tests pending (core conflict resolver + wiring covered). * feat(persistence): CDT-RFC-001 follow-ups — non-disruptive completions, note reconcile, note sync tests (1) Option B — non-disruptive queued completions: - request_completion_runner now DEFERS (throws CompletionBusyException → op stays pending) when a DIFFERENT chat is foregrounded, so a drained background completion never yanks the user to another chat. It drives live (Option A's proven pipeline) only when its own chat is active or none is. - SyncEngine.drainOutbox() (plain drain, no backoff reset) + a SyncTriggers listener on activeConversationProvider so a deferred completion runs the moment the user opens its chat. - (Chosen over a from-scratch headless streaming consumer, which would re-implement ~370 lines of subtle stream-accumulation logic that can't be validated without a live server — strictly riskier than this for an edge-case UX win. A true background-streaming sink remains an optional follow-up.) (2) Note deletion-reconcile (§7.5 for notes): - NoteDeletionReconcile mirrors the chat reconcile over the note list/probe endpoints: purge only on a confirmed-gone probe, 50% safety valve, session- liveness guard, own throttle key (notes_last_full_reconcile_at), note lock domain; wired into SyncEngine background + reconcileNow. (3) Note sync/reconcile integration tests through the real DB: - note_deletion_reconcile_test (8): gone-purge, transient-skip, safety valve, dead-session abort, feature-disabled, throttle - note_sync_test (3): nanosecond-watermark pull (+ chat watermark untouched), the DB-level conflict copy (concurrent data edit → two surviving notes, none lost), updateNoteWithOutbox row+op in one tx - runner tests extended for the Option B deferral policy Full suite 1911 green, analyze clean. * feat(persistence): CDT-RFC-001 Option B — headless background completions (live-validated) Live validation against the env.local server proved the load-bearing fact: Open WebUI persists the assistant message SERVER-SIDE during a completion (server's utils/middleware.py upsert_message_to_chat...; the outlet handler 'replaces the POST /api/chat/completed round-trip'). A probe that fired a real completion and DISCARDED every stream chunk still found the full reply persisted on the chat. Also confirmed: note updated_at is 19-digit NANOSECONDS and chat updated_at is 10-digit SECONDS (D-11/R-09 holds on the real server), and the persisted reply lands in history.messages in the shape the pull-merge consumes. So the headless consumer needs NO re-implementation of stream accumulation — the pattern is fire -> drain -> pull: - runHeadlessCompletion (chat_providers): builds the request from the target chat's DB rows (not the globally-active chatMessagesProvider), fires sendMessageSession, drains the byte stream to EOF so the server runs to completion, then pullChatNow merges the server-persisted reply into the local DB (Phase 3 merge) — bounded poll for the socket/task flow. - request_completion_runner now drives LIVE (Option A) only when the target chat is the one the user is viewing, else runs HEADLESS — never switching the user's active conversation. Replaces the prior defer-only policy. Full suite 1911 green, analyze clean. * refactor(persistence): review + simplify pass over the offline-persistence branch Multi-round adversarial review/simplify loop (findings verified before applying; sync invariants preserved). Full suite 1911 green, analyze clean. Correctness: - note_deletion_reconcile: the session-liveness probe now actually detects a dead session — getNoteListRaw swallows 401/403 and signals via featureEnabled=false, so a thrown error OR (_, false) both abort with no purge (the prior try/catch could not catch the swallowed auth failure) - runHeadlessCompletion: both transport flows now poll pullChatNow with backoff until the reply lands (the server persists asynchronously — ENABLE_REALTIME_CHAT_SAVE defaults False; live-validated that attempt 0 still catches the common case, retries cover the async-persist edge) Simplification / dedup / dead-code (behavior-preserving): - chats_dao: _writeServerRows+_writeMergedRows → one _writeChatRows; shared _activeChatsListQuery() backs watchChatList+getChatPage; _maxLastReadAt via math.max - id_remapper: chat+note FTS remap share _remapFtsRowsWhere - chat_providers: runQueuedCompletion/runHeadlessCompletion share request-build helpers; _modelUsesReasoning + _lastUserMessageId extracted - note_sync/note_mapper/folders_dao/notes_dao: shared decodeJsonMap + asNs - outbox_drainer: dropped 3 unused injected deps; collapsed duplicate branches - pull_sync/chat_merger: removed dead params; optimized_storage_service: deleted the dead legacy-cache migration path - chats_drawer/media_upload_controller: minor dedup + firstOrNull Deliberately NOT applied (documented): cross-file timestamp-helper collapse (load-bearing R-09 ns-vs-seconds semantics), conversation_assembler key-coercion tolerance, and an upload-concurrency change (not behavior-preserving). * refactor(sync): remove dead NotePullSync.run() parallel driver; test the live path Final review round (1 confirmed finding). NotePullSync.run() + pullNote() + _parseListItem + NotePullResult + _ChangedNote + kNotePullFetchConcurrency were a ~95-line parallel copy of the generic runPullFor driver, dead in production (the engine drives note pull EXCLUSIVELY via runPullFor(NoteAdapter), which delegates only getListPageRaw/fetchRaw/mergeNoteResponse). Deleted the copy and rewired note_sync_test to drive the real production path (runPullFor over a NoteAdapter) — so the watermark / conflict-copy / outbox assertions now exercise the code that actually runs. Kept the live seam (NotePushSync, mergeNoteResponse, _asNs, fetch/list helpers). Full suite 1911 green, analyze clean. * fix(sync): address greptile review — reconcile safety-valve floor (P1) + archive race (P2) Greptile branch review (PR #508) found a real defect my own loop missed. P1 (deletion_reconcile + note_deletion_reconcile): the safety valve 'candidates > total * 0.5' tripped for small libraries — a user with ONE server chat/note that was genuinely deleted hit '1 > 0.5 = true', so the valve aborted on every run (even manualRefresh) and the phantom row never purged. Added an absolute floor: the fraction valve now trips only when candidates exceed max(kReconcileMinCandidatesForValve=5, total*0.5). Safe because the session-liveness guard + per-candidate confirmed-gone probe are the real token-expiry protection; the fraction valve is only a coarse backstop against an implausibly-LARGE candidate set. Regression tests added (single deletion now purges; valve still trips above the floor). P2 (push_sync archive toggle): gave the archive toggle the same read-before- toggle race protection the pin path already has — re-read the live archived state via getChatRaw and flip only on a real delta, so a concurrent client can't make it blind-toggle archive back to the wrong state. Full suite 1913 green, analyze clean. * fix(sync): address greptile re-review round 2 (note-pin probe, archive null-guard, dead push, notes reconcile gate) Greptile re-review confirmed the prior P1/P2 resolved, then found 4 more: 1. P1 pushNotePin did a blind toggle-FIRST then corrected, leaving a transient wrong-state window (and the doc claimed it probed first). Now probes live is_pinned via getNoteRaw and toggles only on a real delta — symmetric with the chat pin/archive paths. Regression test added. 2. P2 (regression from the prior archive fix): getChatRaw can 404 if the chat was deleted in the window after updateChat succeeded; toggling a gone chat threw a terminal error and PARKED an op whose updateChat already succeeded. Now skips the toggle when the probe returns null (reconcile purges the row). 3. P2 dead code: _ensureDrainer built a discarded PushSync purely for a null-guard already subsumed by _buildAdapters(). Removed. 4. P2 efficiency: pre-advance the NOTE reconcile gate after a watermark-0 pull (symmetric with the chat gate) so a fresh install skips a redundant getNoteListRaw + full-ID diff right after the first full pull. Full suite 1914 green, analyze clean. * polish(sync): address greptile round-3 minor observations (4/5 'safe to merge') All P1/P2 resolved last round; greptile rated this 'safe to merge'. Cleaning up the remaining minor observations: - note_adapter.listPageSize → large sentinel (1<<30): notes fetch in a single unpaged call, so this stops runPullFor from ever requesting an empty page 2 for users with ≥60 notes (was one no-op Dart loop iteration, no network). - note_conflict.resolveNoteMerge: added an assert(serverUpdatedAt >= base) for parity with chat_merger, surfacing a server-clock regression in tests instead of silently no-op'ing. - note_deletion_reconcile: documented the deliberate trade-off that a Notes- feature-disabled-mid-reconcile reads as session-dead (conservative skip, no data loss; chat side can't conflate because getChatListPage throws on auth). - migrator raw==null: left as-is — the existing comment already documents why the flag stays unset (a later SharedPrefs→Hive migration could still land tasks); marking it migrated would risk dropping those. Full suite 1914 green, analyze clean. * polish(sync): address greptile round-4 style observations (still 4/5 'safe to merge') All correctness concerns resolved in prior rounds; these are the 3 remaining style-level observations: - sync_engine: capture + log the note pull's AdapterPullResult (note-cycle-done telemetry) so a stuck note watermark / failed fetch has success-path observability, mirroring PullSync.run's chat cycle-done log. - SyncEntityAdapter: removed the dead 'locks' getter (never read through the interface — each adapter's real lock domain lives in its injected PullSync/ PushSync). Dropped the now-unused _locks field + constructor param from both ChatAdapter/NoteAdapter and their construction sites; future adapters no longer implement an unused member. - dedup the pull concurrency constant: kPullFetchConcurrency now DERIVES from the canonical kAdapterPullFetchConcurrency (single literal), so chat and note pull concurrency can never silently diverge. Full suite 1914 green, analyze clean. * fix(sync): address greptile round-5 — migration duplicate-turn (P1) + reconcile try/catch (P2) P1 (data corruption for upgrading users): OutboxTaskQueueMigrator's existing- chat branch deduped only on a deterministic v5 message id, which never matches the server's own ids. _runOnce pulls BEFORE migration, so a legacy 'running' task whose completion already finished server-side was re-appended as a DUPLICATE turn + an unwanted second requestCompletion (extra AI generation, corrupted conversation). Added _latestTurnAlreadyCompleted: if the chat's most- recent user message matches the task text and already has a non-empty assistant reply, the turn is done → skip. Targets the latest turn (not any historical message) to avoid false-skipping legitimately-repeated text. Regression test added. P2: split the shared chat+note reconcile try/catch in _runOnce and reconcileNow so an unexpected chat-reconcile error can't skip the note reconcile (each entity now has its own try/catch + log scope). Also fixed a latent log-level bug: the benign FTS db-closed teardown race was still logging at ERROR because the substring check was 'closed' but the SQLite message says 'closing'/'re-open'; now matched and downgraded to debug. Full suite 1915 green, analyze clean. * polish(sync): address greptile round-6 edge cases (active-branch migration guard, pin 404) Both non-blocking P2s (greptile: 4/5 'safe to merge'); refining for correctness: - _latestTurnAlreadyCompleted now follows the ACTIVE branch via currentMessageId (tip → parent user message) instead of orderIndex. orderIndex is a DFS over the full message DAG, so a regeneration branch could lead it and an off-branch message with coincidentally-matching text could false-skip a migration task. Walking currentMessageId is the precise active-branch turn. Test updated. - push pin/archive: ONE getChatRaw liveness fetch now guards BOTH toggles. The pin path previously called getChatPinned with no 404 guard (asymmetric with the archive path); a chat deleted in the window would throw and cost an extra drainer retry cycle. Now a 404 on the shared liveness fetch skips both toggles (reconcile purges the row); getChatPinned is still used for pin accuracy but only after the chat is confirmed to exist. Full suite 1915 green, analyze clean. * fix(sync): address greptile round-7 P1 — migrated turn orphaned in pulled chats The migrator's existing-chat branch always set the new user message's parentId to null. For an already-pulled chat (with prior messages), the migrated turn became a DISCONNECTED root in the conversation DAG: pushing updateChat sent a blob where the new user message floats as a new root, and OpenWebUI's active- branch trace (currentId → parent) reached only the orphan, HIDING all prior history — a permanently malformed conversation. rowsToBlob emits each message's payload verbatim (no childrenIds derivation), so the fix threads existing.currentMessageId (the prior active-branch tip) as the new user message's parentId (column + payload). existing == null (fresh stub) correctly stays a null root. Regression test asserts the migrated turn hangs off the prior tip. Full suite 1916 green, analyze clean. * harden(sync): address greptile round-8 defensive P2s (4/5 'safe to merge') Both are defensive guards for conditions that should not arise in normal operation: - chat_merger: added a release-mode runtime guard for serverUpdatedAt < base. The assert is elided in release, so a clock-skew/server-bug could fall through to fast-forward, overwriting local rows with a STALE server snapshot AND regressing the merge base below its prior value. Now mirrors resolveNoteMerge: leaves local rows untouched (noRemoteChange). The assert stays to surface the anomaly in tests. - chat_adapter.pushOp: requestCompletion (which IS in ownsKind but is dispatched by the drainer's RequestCompletionRunner seam BEFORE the adapter loop) now THROWS instead of silently no-op'ing, so a future reorder that lets it fall through surfaces loudly instead of silently dropping the completion. Full suite 1916 green, analyze clean. * fix(sync): preserve folder on envelope stub refresh * fix(sync): harden migration and folder delete dedupe * fix(sync): park malformed folder delete ops * perf(sync): avoid redundant pull work * fix(sync): dedupe note delete and pin-only pulls * fix(sync): retry task queue migration failures * fix(sync): treat missing folder delete as success * fix(sync): guard corrupted chat merge cycles * fix(sync): defer stub chat update pushes * fix(sync): stop retrying missing folder updates * fix(sync): full-fetch notes and close create remap gap * fix(sync): harden note merge and fts cleanup * fix(sync): remove stale note conflict claims * fix(sync): dedupe pending chat deletes * fix(sync): close note create remap window * fix(sync): retry fts build after watermark advances * test(sync): cover fts build retry failure * fix(sync): prevent recursive note conflicts * fix(sync): preserve dirty stubs during pull * fix(sync): drop local note tombstones * fix(search): page combined results and filter fts backfill * fix(sync): drop annihilated chat and folder stubs * fix(sync): close chat create remap window * fix(sync): park malformed completion ops * docs(sync): update create heal lock comment * fix(sync): address CodeRabbit review findings * fix(sync): preserve folder parent edits * fix(sync): use remapped folder outbox id * fix(sync): clear CodeRabbit folder follow-ups * fix(sync): skip tombstoned note creates * fix(sync): merge coalesced folder upserts * fix(sync): resolve CodeRabbit follow-ups * fix(sync): crash-heal note creates * docs(sync): clarify note create lock ordering * fix(sync): bind cycles to dependency epoch * fix(search): make fts backfill idempotent * fix(sync): harden note and fts error handling * perf(sync): bound outbox and search scans * fix(sync): defer chat create for local folders * chore(sync): tidy note search review items * fix(sync): refresh coalesced note create hash * fix: address CodeRabbit review findings * fix(sync): assert coalesced note row invariant * fix(sync): harden note pull and pin coalescing * fix(sync): clamp null current message ids * fix(sync): refire start and abort terminal reconcile * fix(sync): back off chat folder remap deferral * fix(sync): fail malformed note list pages * fix(sync): skip malformed note list slots * fix(sync): atomically reassert chat merge pushes * fix(sync): address greptile review feedback * fix(sync): keep echo turns and fts deletes consistent * fix(sync): abort chat reconcile on auth probe failure * Fix CodeRabbit sync review issues * fix(sync): harden echo replay and note reconcile * Fix CodeRabbit drain migration issues * fix(sync): preserve dirty chat titles on stub refresh * Fix CodeRabbit replay and remap issues * fix(sync): refresh coalesced chat create hashes * address greptile review feedback (greploop iteration 1) * fix(sync): guard deferred pulls and completions * fix(sync): prevent duplicate headless completions * fix(sync): address CodeRabbit review feedback * fix(sync): isolate outbox queue domains * fix(sync): cleanly cancel pending completions * test(database): avoid duplicate FTS migration message id * fix note pull pagination after malformed pages * fix(chat): preserve image attachment types in durable sends * fix(sync): tighten outbox follow-up handling * fix(sync): unblock deletes and bound pull pagination * fix(sync): guard reconcile pagination * fix(sync): stop pull pagination on malformed pages * Sandbox inline web embeds * fix(sync): harden note merge edge cases * fix(sync): drop stale note pin ops * fix(sync): claim create heals before remap * fix(sync): preserve migrated attachment urls * fix(sync): preserve note edits across create remap * fix(sync): resolve stale update and mime edge cases * fix(sync): purge outbox ops after push deletes * fix(sync): preserve note conflict metadata * fix(sync): remap open note routes * fix(sync): harden note remap edge cases * fix(sync): handle archive confirmation races * fix(sync): purge orphaned folder outbox ops * fix(sync): avoid stale streaming and archive stalls * fix(sync): mirror chat pin races * fix(sync): harden upload and legacy migration races * fix(sync): clarify outbox remap coverage * Address CodeRabbit review findings * Implement chat message queuing and handling for offline scenarios, including UI updates for queued completions and localized messages for user feedback. Enhance the `ChatsDao` with methods to manage queued completions and update assistant message links. Refactor `AssistantMessageWidget` to display queued message states and integrate new localization strings for various languages. * Address Greptile chat merge dedupe finding * Refactor authentication and database handling: Removed redundant checks in `AuthStateManager`, updated database schema documentation, added error handling in conversation loading, and enhanced message retrieval methods in `MessagesDao`. Improved attachment upload handling with cancellation support and refined sync triggers for better error management. * refactor: apply reviewed simplifications and edge-case fixes Round 1 of an automated review+simplify pass over the branch diff. Verified each change is behavior-preserving (or a confirmed edge-case fix) and runs clean through `flutter analyze` + the test suite. Bug/edge-case fixes: - database_manager: track and await pending db close before deleteFor, closing an open/close race - pull_sync: stop double-counting archived-with-synced-body chats in changedChats / cycle-done log - media_upload_controller: clean up converted temp dir on the error/abort path instead of leaking it - sync_triggers: seed foreground state on cold launch so the periodic foreground pull starts when launched already foregrounded Simplifications / dead-code removal: - auth_state_manager: drop unused params and duplicate error log - notes_dao: remove dead `meta` param from updateNoteWithOutbox - search_dao: drop unused `kind` projection from the hits CTE - fts_query: make _quoteToken non-nullable, remove unreachable guard - note_mapper / note_sync: collapse duplicate _decodeMap/_asNs helpers onto the shared decodeJsonMap/asNs - outbox_task_queue_migrator: fetch chat row once instead of twice - push_sync / sync_api_client: remove always-true guard and unreachable 404 branch - backoff: drop redundant .toInt() - chat_transport_dispatch / request_completion_runner / local_conversation_loader: simplify return, fix stale doc comments, drop redundant cast/import - chat_page: fire attachment uploads non-blocking with error logging * fix(sync): keep remap route consumer live across session rebinds remapRouteSyncProvider subscribes to SyncEngine.remapEvents exactly once at startup, but remapEvents returned the current per-session IdRemapper's stream. Every dependency rebind (db/client/auth/lock/clock/backoff/ completion-runner swap) disposes that remapper and lazily mints a new one with a fresh stream, leaving the startup subscription bound to the dead stream — so post-rebind remaps were silently dropped and an open chat route would never swap its local id to the server id in place. Back remapEvents with a long-lived broadcast controller owned by the engine and forward each session's remapper into it, re-establishing the forward on every rebind. The consumer's single subscription now survives rebinds. Verified by remap_route_sync_test + sync_engine_test. * refactor: second review+simplify pass (dead code, parity, doc fixes) Round 2 over the branch diff. Verified behavior-preserving (or a confirmed consistency fix) via flutter analyze + full test suite. Behavior / consistency fixes: - app_providers: Conversations.updateConversation now requests a reconcile pull on a cold/missing chat-list projection instead of silently dropping the envelope mutation, mirroring Folders.updateFolder - database_manager: chain pending db closes (don't drop an in-flight close on rapid switches) and await the pending close in closeActive Dead-code removal / simplification: - sync_api_client: drop the no-op _withTerminalAuth wrapper; the *Raw endpoints already translate 401/403 -> SyncTerminalException, so its auth branch was unreachable. createFolder keeps an explicit inline translation (its endpoint does not translate) - chats_dao: merge adjacent identical `if (placeholder != null)` blocks - folders_dao / note_sync: inline single-use private helpers - deletion_reconcile: drop always-true operand from safety-valve guard - request_completion_runner: gate live-only reads behind the active-chat check via short-circuit Doc-comment corrections: - id_remapper: fix trigger #6 -> #7 FTS citations - api_service / request_completion_runner_provider / app_providers: correct stale references and offline-only comment * fix: third review+simplify pass (verified findings) Applies 17 adversarially-verified findings from a multi-agent review of the branch's changed production files. Correctness: - chat_merger: carry server.unmappableMessageOrder through three-way merge so unmappable history keeps its original positions on round-trip. - chat_page: clear context attachments on durable send (were silently re-sent next message) and recover the streaming placeholder + log on durableSend failure instead of swallowing it. - folder_page: fire attachment uploads concurrently (unawaited + catchError) so one upload failure no longer aborts the remaining attachments. - user_message_bubble: skip note attachments in _inlineEditAttachmentIds so a note id is not re-sent as a bogus file attachment. - home_widget_service: fire photo uploads concurrently and focus the composer once up front rather than after each blocking upload. - deletion_reconcile: log the underlying error/stackTrace on preflight abort. Simplification / docs: - auth_state_manager: drop dead canCommit param from _claimAuthCommit. - chat_providers: drop dead parentId param from _localEchoRow. - chat_transport_dispatch: remove unused writeAbortHandleMetadata. - push_sync: drop redundant trailing return. - note_deletion_reconcile: drop dead localServerIds.isNotEmpty guard. - Doc fixes: chats_dao getChatPage, sync_meta_dao watermark, app_database trigger count, id_remapper outbox-status comment. flutter analyze clean; affected sync/chat/dao/widget suites pass. * fix: fourth review+simplify pass (round 2 verified findings) Three adversarially-verified findings from re-reviewing the round-1 changes. Correctness: - folder_page: recover the streaming placeholder + log on durableSend failure in the folder composer send path (parity with chat_page; was a try/finally with no catch, leaving the assistant bubble stuck isStreaming forever). - push_sync: a folder move forces pinned=false server-side, so run the move BEFORE the pin reconcile and treat the post-move pin as false — a pinned chat moved in the same coalesced update no longer silently loses its pin. Adds a regression test (and teaches the fake server to reset pin on move, mirroring the vendored update_chat_folder_id_by_id_and_user_id). Simplification: - auth_state_manager: drop the dead canCommit/claimCommit/trackAuthAttempt plumbing from _loginInternal/_loginWithApiKeyInternal (their only callers are the public wrappers, which never pass these), removing ~6 always-true branches from security-sensitive code. Silent-login path untouched. flutter analyze clean; push_sync (incl. new pin+move test) and folder_page suites pass. * docs: correct stale _claimAuthCommit comment (round 3) Round 2 removed the two login() call sites that passed claimCommit: null, so _claimAuthCommit's only remaining caller is the silent-login commit, where claimCommit is non-null on the background path. Update the comment to describe the actual foreground(null)/background(non-null) split instead of the now-false "null at every call site" claim. Comment-only; no behavior change. * refactor: drop now-dead clearOnFailure param (round 4) Cascade from round 2/3: once the login methods stopped passing clearOnFailure, no caller supplies it, so the guard `clearOnFailure == null || clearOnFailure()` was always true. Remove the parameter and collapse the catch to the unconditional token clear it always performed. Behavior-preserving. * feat: enhance notes feature with user-specific data and markdown previews - Updated NoteListEntry to include userId and a bounded markdown preview. - Modified watchNotes method to filter notes by userId and return a markdown preview. - Implemented searchNotesByQuery to allow searching notes with user context. - Added functionality to getNoteForUser to restrict note retrieval to the current user. - Introduced caching for feature availability based on user and server context. - Enhanced note context actions to handle markdown previews correctly. - Updated note editor and list views to utilize new data structure and features. This commit improves the user experience by ensuring that notes are displayed and managed according to the authenticated user's context, while also optimizing data handling for markdown previews. * address greptile review feedback (greploop iteration 1) * address greptile review feedback (greploop iteration 2) * refactor: enhance chat request completion logic to handle optimistic placeholders - Updated the logic in ChatRequestCompletionRunner to prevent deferring when the assistant's own optimistic placeholder is active. - Added checks for the active streaming assistant ID and its relation to the current assistant message ID. - Enhanced logging to include additional context data for better debugging. - Introduced a test to verify that the runner does not defer its own optimistic streaming placeholder. This change improves the responsiveness of the chat feature by ensuring that the assistant can continue processing without unnecessary delays when it is actively streaming its own message. * address greptile review feedback (greploop iteration 1) * refactor: apply review + simplification findings (ultracode round 1) Per-file code review + simplification pass over the offline-persistence/sync branch. 34 confirmed, behavior-preserving fixes plus targeted bug fixes: - id_remapper: repoint nested child folders' parent_id on folder remap (prevents orphaned subfolders after an id swap) - share_receiver/chat_page: harden attachment upload loops against a throwing file.length() and run uploads non-blocking - notes_providers: cancel Drift watch subscription on every recompute (remove one-shot onDispose guard that leaked the subscription) - many duplicate-logic, dead-code, and idiom cleanups across daos/sync/mappers Reverted two auto-applied changes that regressed: a streaming_helper snapshot-refresh that contradicts existing tests, and a chats_drawer firstWhereOrNull that pulled in a non-production collection dependency. Verified: flutter analyze clean, full test suite (2099) green. * fix: resolve 4 sync/persistence correctness bugs (ultracode round 2) Adversarially confirmed (with code + openwebui-src evidence) and fixed the real bugs round 1 flagged but deemed unsafe to auto-apply: - outbox_task_queue_migrator: new-chat dedup contentHash folded in per-message wall-clock timestamps, so a partial-failure re-run on a later second produced a different hash, missed dedup, and POSTed a DUPLICATE chat. Blob per-message timestamps are now deterministic (decoupled from the clock); row envelope timestamps unchanged. createChatContentHash (binding contract) untouched. - chat_providers: headless stream drain leaked the HTTP byte-stream subscription/socket on timeout (Future.timeout does not cancel the drain). Now aborts the request (CancelToken) before deferring, in both error branches. - remap_route_sync_provider: an open /folder/<local-id> route was not remapped after a folder id swap, leaving a dead 'unable to load folder' page. Now rewrites the open route to the server id, mirroring the note-route branch. - sync_api_client.updateFolder: a 2xx response with a non-map/null body (server returns 200+null on duplicate-name/db-error while the folder still exists) was treated as a 404 and PURGED the local folder. Null is now reserved for genuine 404s; a 2xx-null is treated as success and keeps the folder. Added regression tests for each. Verified: analyze clean, full suite (2104) green. * refactor: convergence cleanups (ultracode round 3) Re-reviewed the round 1+2 diffs for newly-introduced issues. No bugs found; addressed the remaining low-severity duplication/dead-code: - fts_query: drop redundant terms.isEmpty guard (join already yields '') - note_mapper: inline _serverJsonObjectOrEmpty (now a pure passthrough to _asMap) - remap_route_sync_provider: collapse the duplicated note/folder route helpers into one shared _remappedSingleSegmentRoute, and extract the duplicated log/go/try-catch navigation into a single goRemappedRoute closure - notes_providers: remove the now-dead _watchedDb/_watchedUserId dedup branch (the dispose-before-recompute fix makes the watch always re-subscribe; both branches already cancelled + re-subscribed, so the tracking was inert) Verified: analyze clean, full suite (2104) green. * feat: route note mutations through the durable offline outbox Note create/update/pin/delete and the note editor's save paths were API-first: they wrote through api.* and only persisted after the server responded, so offline edits were lost and never queued — unlike chat, which already uses the *WithOutbox DAO path. (greptile review feedback) - NoteUpdater/NotePinToggler/NoteDeleter now write the row + outbox op transactionally under noteLocks.runExclusive, then kick the drainer; the UI updates from the reactive watchNotes stream. - NoteCreator creates a durable local note + noteCreate op when offline, staying API-first when online so the editor opens on the server id with no local->server remap underneath it. - note_editor_page save/audio/file-remove paths route through a shared durable _persistNoteUpdate helper. - Drop debugPrints that logged note content/data (privacy). - Update NoteUpdater/NotePinToggler tests for durable semantics. * fix: read back durable note edits through the local->server remap Greptile P1: durableUpdate/Pin/Create read back via getNote(id), but the *WithOutbox writers resolve a stale local: id to the remapped server id before mutating. After a create remap, an editor autosave holding the local id wrote the server-id row but read back null -> the editor showed the save as failed and kept stale state. - Add NotesDao.getNoteResolvingRemap; durable helpers use it for read-back. - Test: getNoteResolvingRemap follows a local->server remap. - Make NoteUpdater/NotePinToggler durable tests deterministic with a no-drain SyncEngine override so the queued op stays PENDING. (greploop iteration 2) * fix: take the note lock on the resolved (remapped) id Greptile P1: durable note mutations acquired noteLocks on the caller's stale local: id, but the *WithOutbox writers resolve local->server internally before mutating. The UI write could then serialize on a different key than concurrent pull/push for the same row, interleaving an autosave with a pull merge / outbox push and losing one side's update. Resolve the remap target BEFORE acquiring the lock and use the resolved id for the lock, the DAO write, and the read-back. Replaces the narrower getNoteResolvingRemap read-back helper with NotesDao.resolveNoteRemapTarget. (greploop iteration 3) * fix: use the caller-captured session in _persistNoteUpdate Greptile P1: _persistNoteUpdate re-read the live database/API instead of the session the caller captured before its awaits. If a save, audio attach, or file removal started on one account and the user switched mid in-flight await, the helper could write the old editor's note + content into the newly active account's database. Pass the captured api/db into the helper and bail (return null, persist nothing) when the session is no longer current before writing. (greploop iteration 4) * fix: alias the note lock key on create crash-heal remap Greptile P1: the pull-side create crash-heal remapped a local: note to the server id while holding only the local-id lock but never aliased the lock key. An edit/pin/delete already queued on the stale local id could then run under the local-id lock while the DAO resolves internally to the server id and mutates the server row — interleaving with pull/push that lock on the server id. Mirror the create-push path: call _locks.remapKeyInPlace(localId -> serverId) after remapper.remapNote succeeds so queued mutations reroute to the server-id lock. (greploop iteration 5) * fix: don't publish authenticated without a user on cached-token bootstrap Greptile P1: the stored-token fast-path published AuthStatus.authenticated with user=null when a token existed but no user row was cached, so isAuthenticatedProvider2 became true while currentUserProvider2 stayed null. User-scoped local reads (notes) then cancel their Drift watch and render empty; offline, background validation never recovers the user, so the state stays hidden for the whole session. - When no user is cached, hold the normal startup loading/revalidation state (token kept) instead of authenticating; background validation promotes to authenticated WITH the user once reachable. - Add an offline fallback (_proceedAuthenticatedWithoutCachedUser) so a genuinely-offline no-cached-user bootstrap proceeds instead of hanging on loading — no worse than before for that corner, strictly better online. (greploop iteration 5, auth follow-up) * fix: resolve no-cached-user auth bootstrap to re-login, never userless-auth or hang Greptile P1 follow-ups to the cached-token bootstrap fix: - The offline fallback still published AuthStatus.authenticated without a user, so isAuthenticatedProvider2 was true while currentUserProvider2 was null (user-scoped notes pass the gate then return empty). - A transient (non-auth) getCurrentUser failure only logged, leaving the no-cached-user bootstrap stuck on AuthStatus.loading (splash) forever. Replace the userless-authenticated fallback with _failBootstrapWithoutCachedUser, which resolves the bootstrap to AuthStatus.unauthenticated (needsLogin; token kept) whenever a scoped user can't be recovered — offline, unreachable, user==null, or a transient validation error. Never userless-authenticated, never an indefinite loading hang. The cached-user path (offline-first common case) is unchanged. (greploop iteration 6) * fix: re-check note session after durable await before publishing Greptile P1: the durable update/pin/create provider branches captured api/db, awaited the DAO/lock write, then published the returned note (and updated activeNoteProvider for pin) without re-checking the session. A server/account switch during the await could surface a note from the previous database into the new session. Re-check _isCurrentNoteSession after each durable await before setting shared note state / activeNote (mirrors the API-first branches). Delete is unaffected (publishes only a bool, never a note). (greploop iteration 6) * fix: guard stale session on durable note delete success Greptile P1: the durable delete branch returned true after the DB/outbox await without re-checking the captured session. The editor caller treats true as success and navigates away, so a server/account switch during durableDeleteNote could publish a delete success into the new session. Mirror the create/update/pin post-await guard: re-check _isCurrentNoteSession and report false (no success) when the session changed. (greploop iteration 7) * fix: re-resolve note id after drain in durable update/pin read-back Greptile P1: durableUpdateNote/durablePinNote read back with the id resolved BEFORE the drainer kick. The drain can push a brand-new note and remap its local: id to the server id between the write and the read, so the read looked up the deleted local row and returned null — a false 'save failed' that left the editor on stale state. durableCreateNote already re-resolved post-drain; unify all three through a shared _drainAndReadBackNote helper so update/pin do too. (greploop iteration 8) * fix: hold loading during bootstrap silent login to avoid sign-in flash Greptile P1: on cold start with saved credentials but no stored token, the init branch published AuthStatus.unauthenticated before starting the background silent login. authNavigationStateProvider maps that to needsLogin, so the router briefly showed the sign-in page before a valid silent login completed and bounced the user back to chat. - Hold AuthStatus.loading (splash) while the bootstrap silent login is in flight instead of unauthenticated. - Widen the background canCommit() to accept the loading state so a successful login still commits authenticated. - Wrap the call (_bootstrapSilentLogin) with a safety net that resolves loading -> unauthenticated when the login commits nothing, so failures reach the sign-in page instead of hanging on the splash. (greploop iteration 8, auth follow-up) * fix: final freshness gate before committing silent-login session Greptile P1: _commitSilentLoginResult published the in-memory authenticated state (invalidate providers, _update, install token) after the last _canCommitAuth check without a final gate. A logout / newer auth attempt landing during persistence could let a stale background login resurrect the previous session. Add a final _canCommitAuth check immediately before the in-memory commit; when stale, restore the prior persistence (mirroring the existing setActiveServerId/saveAuthToken stale branches) and return false. (greploop iteration 9, auth follow-up) * fix: gate bootstrap silent-login fallback on the attempt revision Greptile P1: _bootstrapSilentLogin's fallback fired whenever the result was uncommitted and state was loading-without-token — but a newer foreground login that just started is also loading-without-token. A stale bootstrap task could then publish unauthenticated and interrupt the newer login, bouncing the user to sign-in. Capture _authAttemptRevision before the background login and only run the fallback when it is unchanged (no newer login/logout/token-invalidation, all of which bump it via _beginAuthAttempt). (greploop iteration 10, auth follow-up) * fix: clear rejected credentials on background silent-login auth failure Greptile P1: on a confirmed auth failure the background silent-login path returned false without deleting the saved credential or publishing credentialError. _bootstrapSilentLogin then turned that into a generic unauthenticated state, so a remembered-but-rejected credential/JWT was retried on every cold start instead of being cleaned up. Gate the cleanup on staleness (_canCommitAuth) instead of bailing for all background calls: a non-stale confirmed auth failure now deletes the bad credential and publishes credentialError (re-checking freshness after the delete await before committing state). (greploop iteration 11, auth follow-up) * fix: clear credentials for a deleted server on background silent login Greptile P1: when cold start has saved credentials whose server config was removed, the background silent-login branch returned without deleting the credentials or clearing the active server. _bootstrapSilentLogin fell back to unauthenticated, leaving stale creds so every later cold start re-entered the same impossible silent-login path. Gate the missing-server cleanup on staleness (_canCommitAuth) instead of bailing for all background calls: a non-stale attempt now deletes the credentials + clears the active server (re-checking freshness after the delete awaits before publishing the error state). (greploop iteration 12, auth follow-up) * fix: harden note readback + silent-login TOCTOU races (greptile round) - notes: read the just-written row back BEFORE kicking the fire-and-forget drainer (_readBackThenDrainNote). The write is already committed under the note lock, so reading first can't race a push+remap that deletes the local row — no UI-blocking await needed. (notes_providers.dart:145) - auth #1091: when a CLAIMED background silent login fails during persistence, resolve the in-memory state to unauthenticated before rethrowing, so the claim's revision bump doesn't leave cold start stuck on loading. - auth #1191: only clear missing-server credentials/active-server when the stored tuple still matches the attempted one (value-match) and the active server still points at the missing id. - auth #1422: snapshot the attempted credentials and delete on auth failure only if the stored tuple still matches, so a concurrent login's freshly saved credentials aren't clobbered. (greploop iteration 13) * fix: gate foreground silent login + tighten active-server clear - auth #1033: foreground _performSilentLogin now runs under a revision freshness gate (canCommit/claimCommit), so a saved-credential silent login in flight can't publish authenticated/credentialError/error over a newer manual login or logout that bumped _authAttemptRevision. - auth #1209: clear the dangling active server only when a final _canCommitAuth check passes AND it still equals the missing serverId, so a concurrent foreground login/server switch isn't clobbered. (greploop iteration 14) * fix: compare-and-clear active server on missing-server cleanup Greptile P1: the missing-server bootstrap cleanup read getActiveServerId() then cleared it, which it flagged as a clear-after-read race. Add an atomic-style OptimizedStorageService.clearActiveServerIdIfMatches(expected) helper (read + conditional write in one continuation) and use it, so a concurrently-selected active server is never cleared by a stale cleanup. (greploop iteration 15) * refactor(auth-storage): serialize auth read-modify-writes under a lock Greptile flagged the compare-and-clear/restore helpers as non-atomic (Hive has no CAS). Introduce a single OptimizedStorageService._authStateLock (package:synchronized) that serializes ALL writes to the auth token, saved credentials, and active server id, and run the compound read-modify-write helpers under one hold via private _*Unlocked bodies (lock is non-reentrant): - saveAuthToken/deleteAuthToken/saveCredentials/deleteSavedCredentials/ setActiveServerId now lock + delegate to _*Unlocked. - clearActiveServerIdIfMatches: locked compare-and-clear. - deleteSavedCredentialsIfMatches(expected): locked compare-and-delete. - restoreActiveServerAndTokenIfStale(...): locked value-matched restore. - auth_state_manager routes missing-server cleanup, auth-failure credential delete, and _restoreStaleSilentLoginPersistence through these atomic helpers, so a stale silent-login task can no longer clobber a newer login / server selection. Adds synchronized as a direct dependency. (greploop iteration 16) * fix: clear raw active-server id + restore partial silent-login writes - #344: clearActiveServerIdIfMatches / restoreActiveServerAndTokenIfStale compared against getActiveServerId(), which validates against saved configs and returns null once the server is deleted — the exact missing-server case — so the dangling raw preference was never cleared. Compare the RAW stored id (_readActiveServerIdState().rawServerId) under the lock instead. - #1395: _commitSilentLoginResult now restores value-matched persistence whenever a partial write happened (wrotePersistence), not only when the attempt went stale, so a storage failure between setActiveServerId and saveAuthToken can't strand the app on the silent-login server/token. (greploop iteration 17) * fix: read raw Hive active-server id (bypass cache) in compare-and-clear #346: clearActiveServerIdIfMatches / restoreActiveServerAndTokenIfStale read the active-server id via _readActiveServerIdState(), which returns the cached value — and getActiveServerId() may have already validated a deleted server and cached null. The compare then saw null and skipped clearing the dangling raw preference. Read the raw Hive value directly (_rawStoredActiveServerId, bypassing cache + validation) under the lock so a removed server's stale active-server id is detected and cleared. (greploop iteration 18) * fix: gate foreground logins on attempt revision + clear invalid saved tokens - #553: _loginWithApiKeyInternal, _loginInternal, and ldapLogin now capture the auth attempt revision and skip persisting a token / publishing authenticated|error state when _authAttemptSuperseded (a newer login / logout / token-invalidation started). The gate only trips under genuine concurrency, so single logins are unaffected; it stops a slow attempt from overwriting a newer one's session. - #1458: local saved-token validation failures (apiKeyNotSupported, invalid token format, empty token) from _authenticateSavedJwt are now treated as terminal credential failures, so the bad saved credential is value-match cleared instead of being retried on every cold start. (greploop iteration 19) * fix: re-check attempt revision before publishing foreground login state Closes the residual window named in greptile's summary: the foreground login gate checked _authAttemptSuperseded before saveAuthToken, but a newer login/logout could still start during the saveAuthToken/saveCredentials awaits, after which the older attempt would publish authenticated state. _loginWithApiKeyInternal, _loginInternal, and ldapLogin now re-check _authAttemptSuperseded immediately before the _update(authenticated) too, so a superseded attempt never publishes over the newer session. (greploop iteration 20) * fix: roll back superseded foreground login's persisted writes Greptile P1 (#781/#636): the post-persistence freshness gate stopped a superseded login from publishing state but left the tokenA/credentials it had already written to secure storage, so the next cold start could restore the rejected session. Add OptimizedStorageService.deleteAuthTokenIfMatches (locked compare-and- delete) and a _rollbackSupersededLoginWrites helper; the pre-publish superseded branches in _loginWithApiKeyInternal, _loginInternal, and ldapLogin now value-match delete the token (and remembered credentials) they wrote before returning false — never clobbering a newer login's writes. (greploop iteration 21) * fix: roll back token/credentials on failed foreground login Greptile summary residual: a foreground login that threw AFTER writing the token (e.g. saveCredentials or a later step fails) left the token in secure storage, so a cold start could restore a session the user saw fail. _loginWithApiKeyInternal, _loginInternal, and ldapLogin now track the persisted token/credentials and, in their catch, value-match roll them back via _rollbackUncommittedLoginWrites before rethrowing — so a failed login never leaves a restorable session. (greploop iteration 22) * fix: restore interceptor token when a foreground login is superseded Greptile P1: _validateIssuedToken installs the candidate token on the shared ApiService before the staleness check, so a superseded login left tokenA on the interceptor — subsequent requests could use it until the next update. Add _restoreApiServiceTokenToCurrent() and call it in every post-validation superseded branch (pre-save gate, pre-publish gate, and the catch) of _loginWithApiKeyInternal, _loginInternal, and ldapLogin, so the interceptor token is reset to the authoritative current auth state instead of keeping the rejected attempt's token. (greploop iteration 23) * fix: claim own attempt revision in foreground silent login Greptile P1: _performSilentLogin captured _authAttemptRevision without starting its own attempt. A silent re-login (retry / token-invalidation) could see the same revision as an in-flight manual login and claimCommit the OLD saved credentials, superseding the manual login. Call _beginAuthAttempt() up front so silent login owns a distinct revision: a manual login that starts afterward bumps again and wins, and a stale silent login can't commit over it. (greploop iteration 24) * fix: use one credential snapshot for silent-login attempt and cleanup Greptile P1: _performSilentLoginInternal snapshotted credentials, but _performSilentLoginAttempt re-read them for the actual login. If they changed between the two reads and the second set failed terminal validation, the failure cleanup value-match-deleted the FIRST snapshot, leaving the truly-bad credentials in storage to be retried each cold start. Thread the single snapshot into _performSilentLoginAttempt so the attempted login and the cleanup target are always the same credentials. (greploop iteration 25) * fix: roll back LDAP token writes on post-save !ref.mounted returns Greptile P1: ldapLogin had early 'if (!ref.mounted) return false' guards after saveAuthToken/saveCredentials that bypass the catch rollback, leaving an uncommitted LDAP token (and possibly credentials) in secure storage — a cold start could then restore a session that never committed in memory. Each post-save !ref.mounted guard now value-match rolls back persistedToken and writtenCredentials before returning. (greploop iteration 26) * fix: no retry/cancel banner on first send; gate terminal tab offline by cache 1. Queued-completion banner on first prompt of a new chat: queuedCompletionInfoForMessageProvider hid a fresh, never-attempted requestCompletion op only when isOnline. At app/first-send time connectivity is often still resolving (reported not-online), so the retry/cancel banner flashed on the first message. Hide any fresh pending op (no lastError, no nextAttemptAt) regardless of isOnline — it's 'sending', not 'queued'. The banner still appears once the op is genuinely offline-deferred (lastError='offline'), backoff-scheduled, or failed. 2. Terminal tab shown in offline mode even when disabled on the server: the sidebar derived showTerminalTab from terminalAvailableServersProvider with error/loading => true, with NO cached flag (unlike notes/channels). Offline, it always showed. Add a cached terminalFeatureEnabledProvider (per server/user, same _FeatureAvailabilityCache as notes/channels) and a terminalTabVisibleProvider that uses the live server list when resolved (writing back the cache) and the cached last-known value when offline. So a terminal-disabled server no longer surfaces the tab offline. Notes/channels offline gating was already correct (cached per server/user, default true); verified, no change. Tests: queued_completion_provider_test (fresh hidden offline; offline-deferred and parked still show) and terminal_tab_visible_test (live empty/non-empty; offline falls back to cached, not default-true). * fix: don't cache a placeholder empty terminal server list Greptile P1: terminalAvailableServersProvider returns an empty list when terminalServiceProvider is transiently null (startup / auth / active-server rebuild before the API is ready). terminalTabVisibleProvider treated that placeholder as a real 'terminal disabled' probe and cached false, poisoning the per-server/user cache so the offline/error fallback kept hiding the tab for a terminal-enabled server. Only persist the enablement after a real getAvailableServers() succeeds (service present); the null-service placeholder writes nothing. The cache write moves into terminalAvailableServersProvider (gated on a real probe); terminalTabVisibleProvider now just shows live data when resolved and the cached last-known value while loading/errored. * fix: keep terminal availability unresolved (not empty) when no service Greptile P1 (x2): the no-service placeholder still resolved terminalAvailableServersProvider as data:[], which terminalTabVisibleProvider treated as a completed 'terminal disabled' probe — briefly hiding the tab (and clamping navigation away) during startup/auth/rebuild even when the cached flag was enabled. When terminalServiceProvider is null, the provider now stays UNRESOLVED (loading) instead of returning an empty list, so terminalTabVisibleProvider's orElse falls back to the cached last-known value. A real probe (service present) still resolves to the live list and persists the enablement. selected-server consumers already tolerate the loading state (.asData?.value). * fix: derive sidebar create-action terminal visibility from the shared provider Greptile: sidebar_create_action computed terminal-tab presence with its own terminalAvailableServersProvider.maybeWhen(orElse: true), which no longer matched the sidebar's cached-aware terminalTabVisibleProvider after the stay-loading change. The two could disagree (e.g. terminal loading or cached-disabled), shifting the create-action's tab-index mapping out of sync with the rendered tabs and hiding the Channels create action. Both _watchTerminalTabVisible/_readTerminalTabVisible now read terminalTabVisibleProvider — one source of truth shared with the sidebar. * fix: only mark login credentials rollback-owned after they're written Greptile P1: writtenCredentials was set BEFORE saveCredentials succeeded, so if saveAuthToken succeeded and saveCredentials then threw, the catch's _rollbackUncommittedLoginWrites value-match-deleted a pre-existing identical remembered credential this attempt never wrote — losing remembered login data after a transient secure-storage failure. Set writtenCredentials only AFTER saveCredentials completes in _loginWithApiKeyInternal, _loginInternal, and ldapLogin. A failed credential write now leaves writtenCredentials null, so the catch rolls back only the token this attempt actually persisted. * fix: coalesce token-invalidation onto an in-flight silent re-login Greptile P1: onTokenInvalidated() bumped _authAttemptRevision before checking for an in-flight silent login. Two near-simultaneous 401s would have the second call bump the revision (marking the first silentLogin stale) and then skip starting a replacement (reloginInProgress) — the only re-login bailed and the app stayed in tokenExpired despite valid saved credentials. Early-return and coalesce onto the running silent login before any revision bump / token clear / state publish, so the in-flight login resolves normally. * style: group test imports (dart, external, project) per CodeRabbit Reorder imports in queued_completion_provider_test.dart (CodeRabbit finding) and terminal_tab_visible_test.dart for consistency: dart core, then external packages, then conduit project imports. * fix: don't flash queued banner for a transient completion retry The first message of a session can race a cold network/socket connection: the completion's HTTP request (sendMessageSession) fails once, the drainer markFailedRetryable's it with a ~1s backoff, and the auto-retry then succeeds. The previous queued_completion_provider fix still surfaced the retry/cancel banner during that ~1s window, so it flashed on the first chat message. Surface a PENDING completion only when it genuinely needs attention: - offline-deferred (lastError == 'offline'), or - stalled across >= _queuedCompletionStallAttempts (2) attempts. A failed (parked) op always surfaces for manual retry. A single transient first-attempt retry now auto-recovers invisibly. Socket lifecycle: the socket already connects proactively on auth-ready (_scheduleConnect), force-reconnects on connectivity regained, and the completion path ensureConnected's it (1200ms); the residual transient is the cold HTTP request, which the auto-retry recovers. Tests cover transient-hidden and stalled-shown. * feat: warm the API connection on auth so the first completion isn't cold The first chat completion of a session posts to /api/chat/completions via ApiService._dio. On a fresh session that connection is cold, so the first request can lose a TLS/HTTP handshake race and transiently fail (then the outbox auto-retries ~1s later). Warm that exact connection pool as soon as we're authenticated and the API service is ready: _runPostAuthenticationStartup now fires a cheap, fire-and- forget GET /health (ApiService.checkHealth, same _dio the completion POST reuses). The keep-alive connection is then warm for the first send. Pairs with the queued-completion banner fix: warming prevents the cold-start transient in the common case; the banner still stays hidden for any residual transient (e.g. connection idled out before the first send). * fix: keep the socket continuously available across manager rebuilds Root cause of the socket not being connected for the whole session: the socket manager is an async (FutureProvider) keepAlive provider, and socketServiceProvider collapsed its `loading` state to null on every rebuild — so the live socket momentarily read as null (dropping sends to HTTP-only). It also rebuilt needlessly because SocketTransportAvailability (derived fresh from backend config) had no value equality, so each recompute (e.g. when backendConfig resolves shortly after boot) emitted a new instance that forced a manager rebuild even when the transport was unchanged — landing right in the first-message window. Two fixes (no behavior change to genuine server/transport-change recreation): - Add == / hashCode to SocketTransportAvailability so a logically-identical resolved value doesn't churn watchers (incl. the socket manager). - socketServiceProvider now falls back to the manager's currentService while the manager is reloading, instead of returning null, so the live socket stays visible across rebuilds. Tests: socket_transport_availability_test (value equality). * fix: clear the rejected token before coalescing token-invalidation Follow-up to the coalescing fix: onTokenInvalidated early-returned when ANY silent re-login was in flight (manual retry / bootstrap, not just a prior invalidation), skipping the rejected-token cleanup. If that unrelated login then failed, the invalid token lingered and could be restored on the next cold start. Now value-match delete the rejected token (deleteAuthTokenIfMatches, so a fresh token the in-flight login may have saved is never clobbered) BEFORE coalescing. Still no revision bump / no duplicate login, so the in-flight login resolves normally. * fix: preserve note versions/files on editor saves (no data truncation) The note editor's save paths (plain save, audio-attach, remove-file) rebuilt the note `data` map from scratch with only content (+files), dropping `versions` — and the plain save also dropped `files`. Since the durable update persists/sends this map as the note's full data, every save truncated server-managed note data (version history, and attachments on a text-only save). Add _composeUpdatedNoteData: start from the existing note's data (_note.data.toJson() — content/versions/files) and override only content (and files when changed). All three save sites use it, so versions are always preserved and files survive a text-only save. * fix: merge note data patch in durableUpdateNote (preserve versions/files) Greptile: NoteUpdater.updateNote builds a content-only data blob, which (as the note's whole data) dropped versions/files locally and on the next server push — a latent data-loss path (not currently wired to UI, but real). durableUpdateNote now merges the partial `data` patch onto the existing row's data (decodeNoteData(existing) + patch), so content/files override while versions (and anything else) are preserved. This is the single durable chokepoint for both NoteUpdater and the editor; the editor's own _composeUpdatedNoteData still covers its reviewer/no-db API path. Test: NoteUpdater content-only update preserves existing versions and files. * fix: read the merge baseline inside the note lock in durableUpdateNote Greptile: the existing-note read for the data merge ran BEFORE acquiring the note lock, so a concurrent pull/merge (which takes the same note lock) could change the row between the read and the locked write, invalidating the merge baseline and clobbering the concurrent change. Move the getNote + merge inside noteLocks.runExclusive so read+merge+write are atomic w.r.t. other note ops on that id. * fix: editor sends a minimal note-data patch (no stale versions) Greptile: _composeUpdatedNoteData spread the stale in-memory _note.data (including versions); durableUpdateNote then merged {...dbRow, ...patch}, so the editor's stale versions overrode the DB row's current ones — reverting any server-added versions a pull made while the note was open, and pushing the reversion on the next drain. The editor now emits only what it changes (content, plus files when provided). durableUpdateNote's DB-row merge preserves server-managed versions (and files when unchanged) from the CURRENT row. * fix: show typing indicator directly so no empty block is reserved first The typing indicator was a child of the content AnimatedSwitcher, so when the gate elapsed the switcher reserved its full height while fading it up from zero opacity (220ms) on top of the dots' own fade-in. With the queued banner no longer flashing into that slot, the result read as an empty "blocked" block for a beat before the dots appeared. Render the indicator directly (instant) and keep the AnimatedSwitcher only for the content path. * feat: typing indicator becomes the streaming footer, swaps to action row The typing indicator now lives in the action-row position below the message and persists for the whole generation while text/tool-calls/status stream in above it, then crossfades to the action row once streaming completes — matching OpenWebUI. Previously it sat in the content slot and vanished as soon as the first tokens arrived. Also removes the action-row flash: the row is gated behind a settle signal (genuine streaming-end transition or a confirmed responseDone) and is reset while streaming, so a message that is or was streaming never paints the row mid-flight. History messages (never streamed in this widget) still show their row immediately. The footer slot is a single AnimatedSwitcher swapping typing <-> actions; the content slot no longer renders the indicator. Haptics tests now disable animations (the persistent indicator's repeating animation otherwise leaves pending timers at teardown); adds footer-slot + history-message tests. * feat: sidebar generating spinner for active chats (cold open + global) Adds OpenWebUI-style sidebar activity indication for any chat with an active server generation, including on cold open and for generations started by other sessions/devices. - api.checkActiveChats: POST /api/tasks/active/chats {chat_ids} -> {active_chat_ids}; empty input short-circuits, 404 degrades to empty and is cached so older servers aren't re-probed. - ActiveChatsSync (keepAlive): bulk-fetches into activeChatIds on first conversation-list load and on socket reconnect (setAll), and registers a GLOBAL wildcard chat:active handler so the set tracks every chat, not just the locally-streaming one. Activated from post-auth startup. - ConversationTile/Content gain an isGenerating spinner (tap-load spinner takes precedence); the drawer drives it from activeChatIds. Tests: checkActiveChats request shape + 404 caching; tile generating spinner and precedence. * feat: resume streaming when reopening a chat that's still generating Reopening a chat that is still generating on the server now shows the typing indicator and streams content in progressively, instead of an empty/partial response. The server never sends isStreaming, so: - _detectActiveOnOpen: on conversation change, probe the task registry (activeChatIds fast-path, else getTaskIdsByChat). If active, mark the last assistant message isStreaming:true (without clobbering content), pre-seed _observedRemoteTask, and start the remote-task monitor. Skips temporary chats, offline (probe failure), and genuine local streams; re-checks the active conversation after the await. - _syncRemoteTaskStatus: while tasks are active and no local transport owns the chat, adopt growing server content (monotonic-length guard via pullChatOrFetch) so the resume streams in. Final adopt on tasksDone is unchanged. - _isResumeStreamingActive guard: during a resume the progressive poll owns content; passive server refreshes and same-id snapshot adoption are skipped so an isStreaming:false snapshot can't end the resume prematurely. Tests: reopen-active re-engages streaming; settled chat does not; progressive content growth; temporary chats are never probed. * fix: value-match the token delete in onTokenInvalidated non-coalesce path The non-coalesce path called deleteAuthToken() unconditionally, which (when a concurrent foreground login had already saved a fresh token through _authStateLock) could delete that fresh token via lock-serialised save-fresh -> delete-fresh, leaving the app authenticated in memory with no stored token (a cold start then has nothing to restore). Capture the rejected token synchronously at the top of onTokenInvalidated() (before the silent-login check and any await/revision bump) and use deleteAuthTokenIfMatches(rejectedToken) in both the coalesce and non-coalesce paths, mirroring the already-fixed coalesce branch. Greptile #14/#15 (the 4/5 blocker). * fix: session-guard the cached-note fallback in noteById The catch path returned `cachedNote` (read before the detail fetch await) without the session check the success path performs. If the account switched during a failed `getNoteById`, a note from the previous session could leak into the new one. Add the same `ref.mounted` + `_isCurrentNoteSession` guard before returning the cached note. Greptile 4/5 blocker. * fix: re-arm active-chats cold-open fetch when a new socket is bound _initialFetchDone is a one-shot guard that was never reset, so after a logout/login cycle (a new SocketService is bound) the conversations listener skipped the initial bulk active-chats fetch and the new session's sidebar spinners were never populated. Reset the flag when binding a new non-null socket so the next conversation-list resolution re-fetches. (CodeRabbit) * fix: hoist noteById provider reads before the first await ref.watch(apiServiceProvider)/ref.watch(isOnlineProvider) ran after the getNoteForUser await, which is a Riverpod anti-pattern (the dependency may not be tracked and can throw on newer SDK versions). Read all provider deps up-front before any await. Tests that exercise noteById now override isOnlineProvider (previously short-circuited before it was watched). (Greptile) * test: assert the bootstrap silent-login revision-sharing contract Greptile's remaining 4/5 note: the _bootstrapSilentLogin / _performSilentLoginInBackground pair is correct but relies on a subtle revision-sharing pattern (both share the pre-existing revision; a successful login bumps it lazily via claimCommit) with no dedicated test, so a future change could silently break the concurrency contract. Extract the fallback decision into a pure bootstrapShouldFallbackToUnauthenticated() and refactor _bootstrapSilentLogin to use it, then assert the contract: the bootstrap fallback to unauthenticated fires only when nothing committed and the revision is unchanged in the loading/no-token state, and is suppressed by a successful commit, a newer attempt bumping the revision, a published session, or a restored token. (The success path itself is network-bound via an internal ApiService and not unit-testable; this locks in the revision logic.) |
||
|---|---|---|
| .. | ||
| brand_service.dart | ||