Skip to content

Commit 7b02b46

Browse files
committed
fix(search): isolate Google crawl failures and bound embedding writes
1 parent 5206a22 commit 7b02b46

28 files changed

Lines changed: 1416 additions & 75 deletions

.github/workflows/test-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ jobs:
233233
lib/knowledge/__integration__/search-source-progress.integration.ts
234234
lib/knowledge/__integration__/search-source-pagination.integration.ts
235235
lib/knowledge/__integration__/search-reference-batching.integration.ts
236+
lib/knowledge/__integration__/embedding-insert-batches.integration.ts
236237
lib/knowledge/__integration__/kb-block-search.integration.ts
237238
lib/core/outbox/service.integration.ts
238239
lib/knowledge/__integration__/connector-upload.integration.ts

apps/docs/content/docs/search/gmail.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Search schedules syncs hourly. The first sync lists every thread in scope and ca
133133

134134
**Member accounts:** later syncs use each mailbox's Gmail change history, unless the configuration has a search filter. A full relisting runs about weekly, or sooner if Gmail no longer retains the saved history.
135135

136-
**Service account:** each sync revisits the selected active mailboxes and resumes unfinished listings. A failed mailbox read leaves the crawl incomplete; it does not cause existing indexed mail to be deleted from Search.
136+
**Service account:** each sync revisits the selected active mailboxes and resumes unfinished listings. If Google reports that a user's mailbox is not set up or returns a mailbox `failedPrecondition`, Sim records a warning and continues with the remaining users. The crawl stays incomplete and retries affected users on the next scheduled crawl; existing indexed mail is not deleted because a mailbox could not be read. Credential, delegation, and Directory failures still stop the crawl.
137137

138138
Updates, removals, and access refresh in the background. Empty mailboxes and filters with no matches complete normally with zero documents. Threads exceeding indexing size limits are skipped and reconsidered when they change.
139139

@@ -149,7 +149,7 @@ An individual thread failure does not mean the whole mailbox failed. Sim retries
149149
| Reconnect | Click **Reconnect** and authorize the same account again. |
150150
| Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. |
151151
| Directory or delegation error | Check both central crawl scopes, the service-account key, and the Directory administrator's user-read privileges. A normal OAuth account cannot replace the central service account. |
152-
| Gmail access fails for a selected user | Verify delegation is authorized and [Gmail is enabled](https://knowledge.workspace.google.com/admin/gmail/control-gmail-access-for-your-organizations-users) for that primary Workspace account. Set **Users** to accounts with Gmail enabled; leaving it blank includes all active users and can stop sync on a service-access error. Aliases and external accounts cannot be selected. |
152+
| Gmail access fails for a selected user | Verify delegation is authorized and [Gmail is enabled](https://knowledge.workspace.google.com/admin/gmail/control-gmail-access-for-your-organizations-users) for that primary Workspace account. Check the affected users in **Sync history**. Set **Users** to accounts with Gmail enabled if some active users should not be crawled. Aliases and external accounts cannot be selected. |
153153
| A central source indexes mail but a teammate sees no results | Confirm their verified Sim email is the mailbox's primary email and they belong to the Sim organization. Administrators do not receive other people's mailbox access. |
154154

155155
## Self-hosted operator setup

apps/docs/content/docs/search/google-calendar.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Sim indexes event titles, descriptions, times, locations, and the selected atten
131131

132132
Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays, and automatically generated reservation events from Gmail are not indexed. Events Google returns only as free/busy blocks, without searchable details, are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing).
133133

134-
Search schedules syncs hourly. Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled during completed background syncs. Central crawls page through each selected user and resume unfinished work before removing documents no longer listed. Authorization, quota, and provider failures stop the sync rather than treating unread calendars as empty. The first sync may take longer, and results appear as indexing progresses; Search is not a live Calendar read.
134+
Search schedules syncs hourly. Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled during completed background syncs. Central crawls page through each selected user and resume unfinished work before removing documents no longer listed. If an individual user's event listing returns a `403` with no reason or only `forbidden`, Sim records a warning and continues with the remaining users. The crawl stays incomplete and retries affected users on the next scheduled crawl; unread calendars are not treated as empty. Credential, delegation, Directory, and other provider failures still stop the crawl. The first sync may take longer, and results appear as indexing progresses; Search is not a live Calendar read.
135135

136136
## Troubleshooting
137137

@@ -146,7 +146,7 @@ When a sync fails, **Sync history** includes the Google API operation, HTTP stat
146146
| Reconnect | Click **Reconnect** and complete Google authorization again. Allow pop-ups if the connection tab does not open. |
147147
| Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. |
148148
| Service-account authorization or Directory error | Confirm both delegated scopes, enabled APIs, and the Directory administrator's user-read privilege. Check whether delegation still awaits approval or propagation. |
149-
| Calendar is disabled for a selected user | An active Workspace user may have Calendar turned off. [Enable Calendar](https://knowledge.workspace.google.com/admin/users/access/turn-calendar-on-or-off-for-users) for them, or set **Users** to accounts with Calendar enabled. Leaving **Users** blank includes all active users and can stop sync on a service-access error. |
149+
| Calendar access fails for a selected user | Check the affected users in **Sync history**. An active Workspace user may have Calendar turned off, but a `403` alone does not prove this. Check their access to the selected calendars and [Calendar service settings](https://knowledge.workspace.google.com/admin/users/access/turn-calendar-on-or-off-for-users), or set **Users** to accounts that should be crawled. |
150150
| User not found or inactive | Use an active primary email in the same Workspace customer. Aliases, external or guest accounts, suspended users, and archived users cannot be selected. |
151151
| A central source has no results for a teammate | Confirm their primary Workspace email matches their verified Sim email, they belong to the Sim organization, and they are included in **Users**. Check calendar IDs and **Sync history**. |
152152

apps/docs/content/docs/search/google-drive.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,14 @@ Search schedules syncs hourly. Central crawls revisit the selected users' files
155155

156156
## Troubleshooting
157157

158-
**Directory permission sync failed** means Sim could not fully verify group membership. Check the Directory administrator’s access to the affected group and any nested groups; this is separate from file-download access. An incomplete membership read does not replace the last verified membership, which remains subject to freshness checks.
158+
Directory permission warnings mean Sim could not fully verify group membership. When other groups refresh successfully and only nested groups outside your Workspace customer deny access or cannot be found, Sim continues syncing content and reports a partial permission sync. An incomplete membership read does not replace or refresh the last verified membership, which remains subject to freshness checks. Directory-wide failures still stop the sync. Check the Directory administrator’s access to the affected group and nested groups; this is separate from file-download access.
159159

160160
| Problem | Next step |
161161
| --- | --- |
162162
| Google rejects authorization (`unauthorized_client`) | In **Manage Domain Wide Delegation**, verify the numeric **Client ID** matches `client_id` in the JSON key uploaded to Sim and all required scopes appear under **View details**. Check pending approval and allow time for recent changes to propagate. Changing the OAuth consent screen alone does not authorize delegation. |
163163
| Directory access failed | Check all four delegated scopes and the **Directory administrator email** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. |
164164
| Missing files in a central crawl | Check **Users**, folder and file-type filters, and whether selected active Workspace users can download the file and read its permissions. Opening a file alone does not prove either. Check Sync history for errors. Files reachable only by excluded or inactive accounts are not crawled; files with unverified permissions stay hidden. |
165+
| No text could be extracted | Images and scanned PDFs use OCR, but files with no extractable text cannot be indexed. Check whether the original contains readable text; a successful download does not guarantee searchable content. |
165166
| User not found or inactive | Use a primary email in the same Google Workspace customer. Aliases, external or guest accounts, suspended users, and archived users cannot be selected for crawling. |
166167
| A teammate sees no results | Confirm they have joined the Sim organization and their verified Sim email matches the Drive permission or group membership. For member accounts, finish their personal Drive connection too. |
167168
| A public or shared-link file is missing | Check **Openly shared files**. Link-only sharing does not grant Search access. A named user or group permission can still make the file searchable. |

apps/sim/connectors/gmail/company-crawl.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,69 @@ describe('company-wide Gmail indexing', () => {
409409
).rejects.toThrow('403')
410410
})
411411

412+
it('continues after Gmail failedPrecondition with the later mailbox owner ACL intact', async () => {
413+
fetchProvider.mockImplementation(async (url: string, init?: RequestInit) => {
414+
if (
415+
new URL(url).pathname.endsWith('/threads') &&
416+
new Headers(init?.headers).get('Authorization')?.includes(ALICE.email)
417+
) {
418+
return Response.json(
419+
{
420+
error: {
421+
message: 'private provider response',
422+
errors: [{ reason: 'failedPrecondition' }],
423+
},
424+
},
425+
{ status: 400 }
426+
)
427+
}
428+
return providerResponse(url, init)
429+
})
430+
const first = await gmailConnector.listDocuments(
431+
'directory-token',
432+
CONFIG,
433+
undefined,
434+
centralContext()
435+
)
436+
expect(first).toMatchObject({
437+
documents: [],
438+
reconciliationSafe: false,
439+
listingFailures: {
440+
count: 1,
441+
samples: [
442+
{
443+
scope: ALICE.email,
444+
operation: 'gmail.threads.list',
445+
status: 400,
446+
reasons: ['failedPrecondition'],
447+
},
448+
],
449+
},
450+
})
451+
expect(JSON.stringify(first)).not.toContain('private provider response')
452+
const ctx = centralContext()
453+
const second = await gmailConnector.listDocuments(
454+
'directory-token',
455+
CONFIG,
456+
first.nextCursor,
457+
ctx
458+
)
459+
expect(second).toMatchObject({
460+
hasMore: false,
461+
reconciliationSafe: false,
462+
listingFailures: first.listingFailures,
463+
})
464+
expect(second.documents[0].acl).toEqual([`u:${BOB.email}`])
465+
const hydrated = await gmailConnector.getDocument(
466+
'directory-token',
467+
CONFIG,
468+
second.documents[0].externalId,
469+
ctx
470+
)
471+
expect(hydrated?.acl).toEqual([`u:${BOB.email}`])
472+
expect(hydrated?.content).toContain('Bob private body')
473+
})
474+
412475
it('invalidates previous hydration authority when the next mailbox fails', async () => {
413476
const context = centralContext()
414477
const first = await gmailConnector.listDocuments('directory-token', CONFIG, undefined, context)

apps/sim/connectors/google-calendar/company-crawl.test.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,53 @@ describe('Google Calendar company crawl', () => {
340340
expect(next.documents[0].acl).toEqual([`u:${BOB.email}`])
341341
})
342342

343-
it.each([401, 403])(
344-
'propagates provider HTTP %s without completing a user’s listing',
345-
async (status) => {
346-
fetchMock.mockResolvedValue(response({ error: { code: status } }, status))
343+
it.each([
344+
{ status: 401, reason: 'authError' },
345+
{ status: 403, reason: 'insufficientPermissions' },
346+
{ status: 403, reason: 'SERVICE_DISABLED' },
347+
])(
348+
'propagates provider authorization failures without completing a listing: $reason',
349+
async ({ status, reason }) => {
350+
fetchMock.mockResolvedValue(response({ error: { errors: [{ reason }] } }, status))
347351
await expect(
348352
googleCalendarConnector.listDocuments('directory-token', {}, undefined, context())
349353
).rejects.toThrow()
350354
}
351355
)
352356

357+
it('continues another user after an unclassified list access denial and retains its diagnostic', async () => {
358+
fetchMock.mockResolvedValueOnce(response({ error: { code: 403 } }, 403))
359+
const first = await googleCalendarConnector.listDocuments(
360+
'directory-token',
361+
{},
362+
undefined,
363+
context()
364+
)
365+
expect(first).toMatchObject({
366+
documents: [],
367+
hasMore: true,
368+
reconciliationSafe: false,
369+
listingFailures: {
370+
count: 1,
371+
samples: [
372+
{ scope: ALICE.email, operation: 'calendar.events.list', status: 403, reasons: [] },
373+
],
374+
},
375+
})
376+
const second = await googleCalendarConnector.listDocuments(
377+
'directory-token',
378+
{},
379+
first.nextCursor,
380+
context()
381+
)
382+
expect(second.documents[0].acl).toEqual([`u:${BOB.email}`])
383+
expect(second).toMatchObject({
384+
hasMore: false,
385+
reconciliationSafe: false,
386+
listingFailures: first.listingFailures,
387+
})
388+
})
389+
353390
it('skips a user who became inactive before their page and never delegates to them', async () => {
354391
mockGetUser.mockResolvedValueOnce({ ...ALICE, active: false })
355392
const syncContext = context()

apps/sim/connectors/google-drive/directory.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import { listDomainGroups, openGoogleDirectory } from '@/connectors/google-drive/directory'
6+
import { ConnectorDirectoryGroupAccessError } from '@/connectors/source-error'
67

78
const mockFetch = vi.fn()
89

@@ -234,6 +235,61 @@ describe('the membership a directory reports', () => {
234235
})
235236
})
236237

238+
it.each([
239+
{ status: 403, reason: 'forbidden' },
240+
{ status: 404, reason: 'notFound' },
241+
])(
242+
'classifies inaccessible external nested groups explicitly: $status $reason',
243+
async ({ status, reason }) => {
244+
directory({ 'eng@corp.com': [USER('alice@corp.com'), NESTED('restricted@external.com')] })
245+
const healthy = mockFetch.getMockImplementation()!
246+
mockFetch.mockImplementation(async (url: string) => {
247+
if (
248+
decodeURIComponent(new URL(url).pathname).includes('/restricted@external.com/members')
249+
) {
250+
return jsonResponse(
251+
{ error: { errors: [{ reason }], message: 'private detail' } },
252+
status
253+
)
254+
}
255+
return healthy(url)
256+
})
257+
const failure = await membersOf(GROUP).catch((error: unknown) => error)
258+
expect(failure).toBeInstanceOf(ConnectorDirectoryGroupAccessError)
259+
expect(failure).toMatchObject({
260+
cause: { status, diagnostic: { operation: 'directory.members.list', reasons: [reason] } },
261+
})
262+
expect(String(failure)).not.toContain('private detail')
263+
}
264+
)
265+
266+
it.each([
267+
{ email: 'restricted@corp.io', status: 403, reasons: ['forbidden'] },
268+
{ email: 'restricted@external.com', status: 403, reasons: [] },
269+
{ email: 'restricted@external.com', status: 403, reasons: ['forbidden', 'unknownReason'] },
270+
{
271+
email: 'restricted@external.com',
272+
status: 403,
273+
reasons: ['forbidden', 'insufficientPermissions'],
274+
},
275+
{ email: 'restricted@external.com', status: 401, reasons: ['authError'] },
276+
])(
277+
'does not classify uncertain or customer-owned failures as external access failures: $email $status $reasons',
278+
async ({ email, status, reasons }) => {
279+
directory({ 'eng@corp.com': [NESTED(email)] })
280+
const healthy = mockFetch.getMockImplementation()!
281+
mockFetch.mockImplementation(async (url: string) => {
282+
if (decodeURIComponent(new URL(url).pathname).includes(`/${email}/members`)) {
283+
return jsonResponse({ error: { errors: reasons.map((reason) => ({ reason })) } }, status)
284+
}
285+
return healthy(url)
286+
})
287+
const failure = await membersOf(GROUP).catch((error: unknown) => error)
288+
expect(failure).not.toBeInstanceOf(ConnectorDirectoryGroupAccessError)
289+
expect(failure).toMatchObject({ status })
290+
}
291+
)
292+
237293
/** A directory that hiccups must not cost a group its membership; transient errors are retried. */
238294
it('retries a transient directory error before giving up', async () => {
239295
directory({ 'eng@corp.com': [USER('alice@corp.com')] })

apps/sim/connectors/google-drive/directory.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
fetchGoogleDriveWithRetry,
1717
GoogleDriveApiError,
1818
} from '@/connectors/google-drive/google-drive-errors'
19+
import { ConnectorDirectoryGroupAccessError } from '@/connectors/source-error'
1920
import type {
2021
ConnectorDirectory,
2122
ConnectorDirectoryGroup,
@@ -249,7 +250,28 @@ async function listGroupMembers(
249250
return
250251
}
251252

252-
for (const member of await membersOf(groupId)) {
253+
let members: RawMember[]
254+
try {
255+
members = await membersOf(groupId)
256+
} catch (error) {
257+
const groupDomain = emailDomain(groupId)
258+
if (
259+
depth > 0 &&
260+
groupDomain &&
261+
!customerDomains.includes(groupDomain) &&
262+
error instanceof GoogleDriveApiError &&
263+
error.reasonsComplete &&
264+
((error.status === 403 && error.reasons.length === 1 && error.reasons[0] === 'forbidden') ||
265+
(error.status === 404 && error.reasons.length === 1 && error.reasons[0] === 'notFound'))
266+
) {
267+
throw new ConnectorDirectoryGroupAccessError('An external nested group cannot be read', {
268+
cause: error,
269+
})
270+
}
271+
throw error
272+
}
273+
274+
for (const member of members) {
253275
if (member.status && member.status.toUpperCase() !== 'ACTIVE') continue
254276
const type = member.type?.toUpperCase()
255277

0 commit comments

Comments
 (0)