@@ -3,9 +3,14 @@ import { readFile } from 'node:fs/promises'
33import { parseEnv } from 'node:util'
44import { db } from '@sim/db'
55import {
6+ credential ,
7+ credentialGroup ,
68 document ,
79 embedding ,
810 knowledgeConnector ,
11+ knowledgeConnectorMember ,
12+ knowledgeConnectorMemberSyncLog ,
13+ resourcePolicy ,
914 user ,
1015 workspace ,
1116 workspaceFiles ,
@@ -15,15 +20,24 @@ import { generateId } from '@sim/utils/id'
1520import { eq , inArray } from 'drizzle-orm'
1621import { PDFDocument } from 'pdf-lib'
1722import sharp from 'sharp'
18- import { afterAll , beforeAll , describe , expect , it } from 'vitest'
23+ import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
1924import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
2025import { env } from '@/lib/core/config/env'
21- import { seedKnowledgeAclFixture } from '@/lib/knowledge/__integration__/seed-source-access-fixture'
26+ import { compileCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy'
27+ import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
28+ import { encryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
29+ import {
30+ seedKnowledgeAclFixture ,
31+ seedKnowledgeMemberFixture ,
32+ } from '@/lib/knowledge/__integration__/seed-source-access-fixture'
33+ import { syncKnowledgeConnector } from '@/lib/knowledge/application/connectors'
2234import { searchKnowledge } from '@/lib/knowledge/application/search'
35+ import { grantKnowledgeConnectorCredentialAccess } from '@/lib/knowledge/connectors/member-access'
2336import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock'
2437import { addDocument , persistDocumentAcls } from '@/lib/knowledge/connectors/sync-persistence'
2538import { processDocument } from '@/lib/knowledge/documents/document-processor'
2639import { processDocumentAsync } from '@/lib/knowledge/documents/service'
40+ import { QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types'
2741import { generateEmbeddings } from '@/lib/knowledge/embeddings'
2842import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance'
2943import { deleteFile } from '@/lib/uploads/core/storage-service'
@@ -145,6 +159,304 @@ describe.skipIf(!credentialsFile)('real embedding and scanned PDF providers', ()
145159 expect ( result . results . map ( ( row ) => row . documentId ) ) . toContain ( created . documentId )
146160 } , 120000 )
147161
162+ it ( 'recovers unchanged Gmail documents after a real embedding rejection through manual sync' , async ( ) => {
163+ const previous = {
164+ OPENAI_API_KEY : env . OPENAI_API_KEY ,
165+ GOOGLE_CLIENT_ID : env . GOOGLE_CLIENT_ID ,
166+ GOOGLE_CLIENT_SECRET : env . GOOGLE_CLIENT_SECRET ,
167+ }
168+ const fetchProvider = globalThis . fetch
169+ let rejectedEmbeddingRequests = 0
170+ let hydratedThreads = 0
171+ /** Only Gmail HTTP is a fixture. OpenAI receives real requests and returns real errors/vectors. */
172+ const provider = vi . spyOn ( globalThis , 'fetch' ) . mockImplementation ( async ( input , init ) => {
173+ const url = new URL ( input instanceof Request ? input . url : input )
174+ if ( url . origin === 'https://api.openai.com' ) {
175+ const response = await fetchProvider ( input , init )
176+ if ( response . status === 401 ) rejectedEmbeddingRequests ++
177+ return response
178+ }
179+ if ( url . origin !== 'https://gmail.googleapis.com' ) {
180+ throw new Error ( `Unexpected recovery fixture request: ${ url . origin } ${ url . pathname } ` )
181+ }
182+ if ( url . pathname . endsWith ( '/labels' ) ) return Response . json ( { labels : [ ] } )
183+ if ( url . pathname . endsWith ( '/threads' ) )
184+ return Response . json ( { threads : [ { id : 'unchanged-recovery-thread' } ] } )
185+ expect ( url . pathname . endsWith ( '/threads/unchanged-recovery-thread' ) ) . toBe ( true )
186+ const metadata = { id : 'unchanged-recovery-thread' , historyId : '100' }
187+ if ( url . searchParams . get ( 'format' ) === 'minimal' ) return Response . json ( metadata )
188+ hydratedThreads ++
189+ return Response . json ( {
190+ ...metadata ,
191+ messages : [
192+ {
193+ id : 'unchanged-recovery-message' ,
194+ threadId : metadata . id ,
195+ internalDate : '1700000000000' ,
196+ payload : {
197+ mimeType : 'text/plain' ,
198+ headers : [ { name : 'Subject' , value : 'Kestrel invoice exception approved' } ] ,
199+ body : {
200+ data : Buffer . from (
201+ 'Morgan Ellis approved net-45 payment terms for the Kestrel invoice. Reference KESTREL-RECOVERY-7477.'
202+ ) . toString ( 'base64url' ) ,
203+ } ,
204+ } ,
205+ } ,
206+ ] ,
207+ } )
208+ } )
209+ try {
210+ Object . assign ( env , {
211+ GOOGLE_CLIENT_ID : 'gmail-recovery-fixture-client' ,
212+ GOOGLE_CLIENT_SECRET : 'gmail-recovery-fixture-secret' ,
213+ } )
214+ const fixture = await seedKnowledgeMemberFixture ( ids )
215+ const policy = await getCredentialGroupProviderAdapter ( 'gmail' ) . getPolicy ( undefined , {
216+ workspaceId : ids . workspaceId ,
217+ credentialGroupId : fixture . groupId ,
218+ credentialGroupOptionId : fixture . optionId ,
219+ } )
220+ await db
221+ . update ( credentialGroup )
222+ . set ( {
223+ options : [
224+ {
225+ id : fixture . optionId ,
226+ provider : 'gmail' ,
227+ label : 'Gmail recovery fixture' ,
228+ authorizationAppId : policy . authorizationAppId ,
229+ requiredScopes : policy . requiredScopes ,
230+ scopeVersion : policy . scopeVersion ,
231+ required : false ,
232+ status : 'active' ,
233+ } ,
234+ ] ,
235+ } )
236+ . where ( eq ( credentialGroup . id , fixture . groupId ) )
237+ await db
238+ . update ( knowledgeConnector )
239+ . set ( {
240+ connectorType : 'gmail' ,
241+ sourceConfig : { maxThreads : 0 } ,
242+ status : 'active' ,
243+ memberSyncStatus : 'idle' ,
244+ memberSyncLockToken : null ,
245+ } )
246+ . where ( eq ( knowledgeConnector . id , fixture . connectorId ) )
247+ for ( const member of fixture . members ) {
248+ await db
249+ . update ( credential )
250+ . set ( {
251+ providerId : 'google-email' ,
252+ authorizationAppId : policy . authorizationAppId ,
253+ managedOauthScopeVersion : policy . scopeVersion ,
254+ grantedScopes : policy . requiredScopes ,
255+ encryptedOauthTokenSet : await encryptManagedOAuthTokenSet ( {
256+ accessToken : 'gmail-recovery-fixture' ,
257+ } ) ,
258+ accessTokenExpiresAt : new Date ( Date . now ( ) + 60 * 60 * 1000 ) ,
259+ } )
260+ . where ( eq ( credential . id , member . credentialId ) )
261+ await db
262+ . update ( knowledgeConnectorMember )
263+ . set ( {
264+ subjectToken : `s:google-email:fixture-domain:${ member . userId } ` ,
265+ } )
266+ . where ( eq ( knowledgeConnectorMember . id , member . id ) )
267+ }
268+ await db
269+ . insert ( resourcePolicy )
270+ . values ( {
271+ id : generateId ( ) ,
272+ workspaceId : ids . workspaceId ,
273+ resourceType : 'credential_group' ,
274+ resourceId : fixture . groupId ,
275+ document : compileCredentialGroupWorkflowAccessPolicy ( {
276+ credentialGroupId : fixture . groupId ,
277+ allowedWorkflowIds : [ ] ,
278+ } ) ,
279+ createdBy : ids . aliceId ,
280+ updatedBy : ids . aliceId ,
281+ } )
282+ . onConflictDoNothing ( )
283+ await grantKnowledgeConnectorCredentialAccess (
284+ {
285+ workspaceId : ids . workspaceId ,
286+ credentialGroupId : fixture . groupId ,
287+ credentialGroupOptionId : fixture . optionId ,
288+ connectorId : fixture . connectorId ,
289+ } ,
290+ ids . aliceId
291+ )
292+
293+ const documents = ( ) =>
294+ db
295+ . select ( )
296+ . from ( document )
297+ . where ( eq ( document . connectorId , fixture . connectorId ) )
298+ . orderBy ( document . id )
299+ let runCount = 0
300+ async function manualSync ( ) {
301+ await syncKnowledgeConnector . execute ( {
302+ principal : {
303+ kind : 'session' ,
304+ userId : ids . aliceId ,
305+ sessionId : 'synthetic-indexing-recovery' ,
306+ } ,
307+ input : {
308+ assertedWorkspaceId : ids . workspaceId ,
309+ connectorId : fixture . connectorId ,
310+ source : 'ui' ,
311+ } ,
312+ } )
313+ runCount ++
314+ await expect
315+ . poll (
316+ async ( ) => {
317+ const logs = await db
318+ . select ( { status : knowledgeConnectorMemberSyncLog . status } )
319+ . from ( knowledgeConnectorMemberSyncLog )
320+ . where ( eq ( knowledgeConnectorMemberSyncLog . connectorId , fixture . connectorId ) )
321+ return logs . filter ( ( log ) => log . status === 'completed' ) . length
322+ } ,
323+ { timeout : 60000 , interval : 100 }
324+ )
325+ . toBe ( runCount )
326+ }
327+ const search = ( ) =>
328+ searchKnowledge . execute ( {
329+ principal : {
330+ kind : 'session' ,
331+ userId : ids . aliceId ,
332+ sessionId : 'synthetic-indexing-recovery' ,
333+ } ,
334+ input : {
335+ workspaceId : ids . workspaceId ,
336+ knowledgeBaseIds : [ ids . knowledgeBaseId ] ,
337+ query : 'Who approved the Kestrel invoice payment terms?' ,
338+ topK : 3 ,
339+ } ,
340+ } )
341+ Object . assign ( env , { OPENAI_API_KEY : 'sk-synthetic-invalid-indexing-recovery' } )
342+ await manualSync ( )
343+ await expect
344+ . poll ( async ( ) => ( await documents ( ) ) . map ( ( row ) => row . processingStatus ) , {
345+ timeout : 60000 ,
346+ interval : 100 ,
347+ } )
348+ . toEqual ( [ 'failed' , 'failed' ] )
349+ const failed = await documents ( )
350+ expect ( rejectedEmbeddingRequests ) . toBe ( 2 )
351+ expect ( failed . every ( ( row ) => row . processingError && row . processingAttempts === 1 ) ) . toBe ( true )
352+ expect (
353+ await db
354+ . select ( )
355+ . from ( embedding )
356+ . where (
357+ inArray (
358+ embedding . documentId ,
359+ failed . map ( ( row ) => row . id )
360+ )
361+ )
362+ ) . toEqual ( [ ] )
363+ const hydratedBeforeRecovery = hydratedThreads
364+
365+ Object . assign ( env , { OPENAI_API_KEY : previous . OPENAI_API_KEY } )
366+ expect (
367+ ( await search ( ) ) . results . filter ( ( row ) => failed . some ( ( item ) => item . id === row . documentId ) )
368+ ) . toEqual ( [ ] )
369+ await manualSync ( )
370+ expect (
371+ ( await documents ( ) ) . map ( ( row ) => ( {
372+ id : row . id ,
373+ hash : row . contentHash ,
374+ status : row . processingStatus ,
375+ attempts : row . processingAttempts ,
376+ } ) )
377+ ) . toEqual (
378+ failed . map ( ( row ) => ( { id : row . id , hash : row . contentHash , status : 'failed' , attempts : 1 } ) )
379+ )
380+
381+ /** Age only this fixture's document lifecycle timestamps, preserving their order and the unchanged content. */
382+ const elapsedGraceMs = QUEUED_DISPATCH_GRACE_MS + 1000
383+ for ( const row of failed ) {
384+ const age = ( value : Date | null ) => value && new Date ( value . getTime ( ) - elapsedGraceMs )
385+ await db
386+ . update ( document )
387+ . set ( {
388+ uploadedAt : age ( row . uploadedAt ) ! ,
389+ processingQueuedAt : age ( row . processingQueuedAt ) ,
390+ processingStartedAt : age ( row . processingStartedAt ) ,
391+ processingCompletedAt : age ( row . processingCompletedAt ) ,
392+ } )
393+ . where ( eq ( document . id , row . id ) )
394+ }
395+ await manualSync ( )
396+ await expect
397+ . poll ( async ( ) => ( await documents ( ) ) . map ( ( row ) => row . processingStatus ) , {
398+ timeout : 60000 ,
399+ interval : 100 ,
400+ } )
401+ . toEqual ( [ 'completed' , 'completed' ] )
402+ const recovered = await documents ( )
403+ expect (
404+ recovered . map ( ( row ) => ( {
405+ id : row . id ,
406+ externalId : row . externalId ,
407+ hash : row . contentHash ,
408+ storageKey : row . storageKey ,
409+ } ) )
410+ ) . toEqual (
411+ failed . map ( ( row ) => ( {
412+ id : row . id ,
413+ externalId : row . externalId ,
414+ hash : row . contentHash ,
415+ storageKey : row . storageKey ,
416+ } ) )
417+ )
418+ expect (
419+ recovered . every ( ( row ) => row . processingError === null && row . processingAttempts === 0 )
420+ ) . toBe ( true )
421+ expect ( hydratedThreads ) . toBe ( hydratedBeforeRecovery )
422+ const vectors = await db
423+ . select ( )
424+ . from ( embedding )
425+ . where (
426+ inArray (
427+ embedding . documentId ,
428+ recovered . map ( ( row ) => row . id )
429+ )
430+ )
431+ expect ( vectors ) . toHaveLength ( 2 )
432+ expect (
433+ vectors . every (
434+ ( row ) => row . embedding ?. length === 1536 && row . embedding . every ( Number . isFinite )
435+ )
436+ ) . toBe ( true )
437+ const aliceDocument = recovered . find ( ( row ) =>
438+ row . externalId ?. startsWith ( `member:${ fixture . members [ 0 ] . id } :` )
439+ ) !
440+ const bobDocument = recovered . find ( ( row ) => row . id !== aliceDocument . id ) !
441+ const results = await search ( )
442+ expect ( results . results [ 0 ] ?. documentId ) . toBe ( aliceDocument . id )
443+ expect ( results . results . some ( ( row ) => row . documentId === bobDocument . id ) ) . toBe ( false )
444+ logger . info ( 'Real OpenAI indexing recovery through unchanged-source manual sync' , {
445+ rejectedEmbeddingRequests,
446+ retryGraceMs : QUEUED_DISPATCH_GRACE_MS ,
447+ fixtureLifecycleTimestampsAged : true ,
448+ completedDocuments : recovered . length ,
449+ sourceContentUnchanged : true ,
450+ documentIdsAndStorageUnchanged : true ,
451+ authorizedDocumentRank : 1 ,
452+ peerDocumentExcluded : true ,
453+ } )
454+ } finally {
455+ Object . assign ( env , previous )
456+ provider . mockRestore ( )
457+ }
458+ } , 180000 )
459+
148460 it ( 'ranks synthetic integration documents with real OpenAI embeddings and excludes private source content' , async ( ) => {
149461 const corpus = [
150462 {
0 commit comments