@@ -43,6 +43,67 @@ function quickHash(data: unknown): string {
4343 return JSON . stringify ( data ) ;
4444}
4545
46+ /** Canonical fingerprint for the vector search config.
47+ *
48+ * The backend GET payload and the store config have different key sets/order
49+ * (backend adds authTokenStatus/status/lastSyncAt, the store always carries
50+ * embeddingFormatVersion). A naive quickHash over each raw side therefore never
51+ * matches, which kept the poll→push loop running forever. Fingerprinting only the
52+ * shared, meaningful fields makes both sides converge after one round-trip.
53+ */
54+ export function vectorSearchFingerprint ( config : unknown ) : string {
55+ const c = ( config ?? { } ) as Record < string , unknown > ;
56+ return quickHash ( {
57+ enabled : ! ! c . enabled ,
58+ workerUrl : c . workerUrl ?? '' ,
59+ authToken : c . authToken ?? '' ,
60+ embeddingConfigId : c . embeddingConfigId ?? '' ,
61+ indexMode : c . indexMode ?? 'readme' ,
62+ readmeMaxChars : c . readmeMaxChars ?? 6000 ,
63+ searchThreshold : c . searchThreshold ?? 0.35 ,
64+ searchTopK : c . searchTopK ?? 30 ,
65+ enableHyDE : c . enableHyDE ?? true ,
66+ enableReranking : c . enableReranking ?? true ,
67+ embeddingFormatVersion : c . embeddingFormatVersion ?? null ,
68+ } ) ;
69+ }
70+
71+ /**
72+ * Decide whether a fresh/empty backend must NOT overwrite a locally configured
73+ * vector search. Mirrors the repositories bootstrap guard: on the first-ever sync
74+ * in this session, an empty backend (nothing stored) must not wipe a working local
75+ * config — otherwise the local worker/embedding setup is lost and the follow-up
76+ * push then persists the wiped (empty) config to the backend. When true, the
77+ * caller keeps the local config and pushes it up instead.
78+ */
79+ export function shouldPreserveLocalVectorSearch (
80+ backendConfig : unknown ,
81+ localConfig : unknown ,
82+ isFirstSync : boolean
83+ ) : boolean {
84+ if ( ! isFirstSync ) return false ;
85+ const b = ( backendConfig ?? { } ) as Record < string , unknown > ;
86+ const l = ( localConfig ?? { } ) as Record < string , unknown > ;
87+ const backendEmpty = ! b . enabled && ! b . workerUrl && ! b . embeddingConfigId ;
88+ const localConfigured = ! ! ( l . enabled || l . workerUrl || l . embeddingConfigId ) ;
89+ return backendEmpty && localConfigured ;
90+ }
91+
92+ /**
93+ * True when the backend's stored vector-search authToken is unusable (empty or
94+ * failed to decrypt) but the local client has a working token to preserve. The
95+ * caller must then queue a repair push: during a pull the store subscription is
96+ * suppressed, so without it the preserved token would never reach the backend,
97+ * the stored fingerprint (with the local token) would never match the backend's
98+ * empty token, and the poll→push loop would run forever.
99+ */
100+ export function shouldQueueVectorSearchRepairPush ( backendConfig : unknown , localConfig : unknown ) : boolean {
101+ const b = ( backendConfig ?? { } ) as Record < string , unknown > ;
102+ const l = ( localConfig ?? { } ) as Record < string , unknown > ;
103+ const backendTokenUnusable = b . authTokenStatus === 'decrypt_failed' || ! b . authToken ;
104+ return backendTokenUnusable && ! ! l . authToken ;
105+ }
106+
46107function setRepositorySyncVisualState ( isSyncing : boolean ) : void {
47108 if ( typeof window === 'undefined' ) return ;
48109 window . dispatchEvent ( new CustomEvent ( 'gsm:repository-sync-visual-state' , { detail : { isSyncing } } ) ) ;
@@ -171,9 +232,8 @@ export async function syncFromBackend(): Promise<void> {
171232 }
172233
173234 if ( vectorSearchResult . status === 'fulfilled' ) {
174- const hash = quickHash ( vectorSearchResult . value ) ;
235+ const hash = vectorSearchFingerprint ( vectorSearchResult . value ) ;
175236 if ( hash !== _lastHash . vectorSearch ) {
176- hashes . vectorSearch = hash ;
177237 changed . vectorSearch = true ;
178238 }
179239 }
@@ -277,16 +337,27 @@ export async function syncFromBackend(): Promise<void> {
277337 }
278338 if ( changed . vectorSearch && vectorSearchResult . status === 'fulfilled' ) {
279339 const backendConfig = vectorSearchResult . value ;
280- // Preserve local authToken if backend returned empty or decrypt_failed
281340 const localConfig = state . vectorSearchConfig ;
282- if ( ( backendConfig as Record < string , unknown > ) . authTokenStatus === 'decrypt_failed' || ! backendConfig . authToken ) {
283- if ( localConfig . authToken ) {
341+ // Bootstrap guard (mirrors repositories): a fresh/empty backend must not
342+ // wipe a locally-configured vector search — keep local and push it up.
343+ if ( shouldPreserveLocalVectorSearch ( backendConfig , localConfig , _lastHash . vectorSearch === '' ) ) {
344+ _hasPendingPush = true ;
345+ } else {
346+ // Preserve local authToken if backend returned empty or decrypt_failed,
347+ // and queue a repair push so the backend is re-synced with it. Without
348+ // the push the preserved token stays local-only and the poll loop keeps
349+ // re-detecting the backend/effective fingerprint mismatch.
350+ if ( shouldQueueVectorSearchRepairPush ( backendConfig , localConfig ) ) {
284351 logger . warn ( 'sync.decryptFailed' , 'Backend decrypt_failed for vector search authToken, preserving local value' ) ;
285352 backendConfig . authToken = localConfig . authToken ;
353+ _hasPendingPush = true ;
286354 }
355+ state . setVectorSearchConfig ( backendConfig ) ;
356+ // Fingerprint the effective store config (after merge/normalization) so it
357+ // matches the push fingerprint in syncToBackend(); hashing the raw backend
358+ // payload instead would leave the two sides forever unequal.
359+ _lastHash . vectorSearch = vectorSearchFingerprint ( useAppStore . getState ( ) . vectorSearchConfig ) ;
287360 }
288- state . setVectorSearchConfig ( backendConfig ) ;
289- _lastHash . vectorSearch = hashes . vectorSearch ;
290361 }
291362 // Sync active selections from settings
292363 if ( changed . settings && settingsResult . status === 'fulfilled' ) {
@@ -404,7 +475,7 @@ export async function syncToBackend(): Promise<void> {
404475 if ( aiSync . status === 'fulfilled' ) _lastHash . ai = quickHash ( state . aiConfigs ) ;
405476 if ( webdavSync . status === 'fulfilled' ) _lastHash . webdav = quickHash ( state . webdavConfigs ) ;
406477 if ( embeddingSync . status === 'fulfilled' ) _lastHash . embedding = quickHash ( state . embeddingConfigs ) ;
407- if ( vectorSearchSync . status === 'fulfilled' ) _lastHash . vectorSearch = quickHash ( state . vectorSearchConfig ) ;
478+ if ( vectorSearchSync . status === 'fulfilled' ) _lastHash . vectorSearch = vectorSearchFingerprint ( state . vectorSearchConfig ) ;
408479 if ( settingsSync . status === 'fulfilled' ) {
409480 _lastHash . settings = quickHash ( {
410481 activeAIConfig : state . activeAIConfig ,
0 commit comments