Skip to content

Commit 47d94a5

Browse files
committed
fix(knowledge): treat Google Workspace users without Gmail or Calendar as out of scope, not listing failures
Admin-mode Gmail recorded a Directory user without a mailbox, and Calendar recorded a 403 notACalendarUser, as per-user listing failures. Both are standing account properties, so the connector stayed partial, deletion reconciliation never ran, and the scheduler re-probed the same accounts every retry window. - Directory enumeration no longer schedules Gmail users whose mailbox is not set up; the hourly Directory refresh picks them up once provisioned. A partition queued earlier completes with an empty page instead of a failure. - A Calendar 403 whose only reason is notACalendarUser completes the user's partition cleanly and re-probes it no sooner than the Directory refresh (permissions keep their own refresh cadence). Bare forbidden and mixed-reason 403s stay retryable failures.
1 parent 666b4d3 commit 47d94a5

4 files changed

Lines changed: 157 additions & 50 deletions

File tree

‎apps/sim/connectors/google-workspace/company-crawl.test.ts‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -483,44 +483,52 @@ describe('Google Workspace per-user central crawl', () => {
483483
}
484484
)
485485

486-
it('skips an explicitly unprovisioned Gmail mailbox before requesting a token', async () => {
486+
it('skips an explicitly unprovisioned Gmail mailbox without a token or a listing failure', async () => {
487487
directory([USER('alice', undefined, { isMailboxSetup: false }), USER('bob')])
488-
const ctx = context()
488+
const ctx: Record<string, unknown> = context()
489489
const first = await list(ctx)
490-
expect(first.listingFailures?.samples[0]).toEqual({
491-
scope: 'alice@corp.com',
492-
operation: 'directory.users.get',
493-
reasons: ['mailboxNotSetup'],
494-
})
490+
expect(first).toMatchObject({ documents: [], hasMore: true })
491+
expect(first.listingFailures).toBeUndefined()
492+
expect(first.reconciliationSafe).toBeUndefined()
493+
expect(ctx.reconciliationUnsafe).toBeUndefined()
495494
expect(ctx.getDelegatedAccessToken).not.toHaveBeenCalled()
496495
const second = await list(context(), first.nextCursor)
497496
expect(second.documents[0].acl).toEqual(['u:bob@corp.com'])
497+
expect(second.listingFailures).toBeUndefined()
498498
})
499499

500500
it('does not use Gmail mailbox eligibility for Calendar', async () => {
501501
directory([USER('alice', undefined, { isMailboxSetup: false })])
502502
expect((await list(context(), undefined, CONFIG, 'google_calendar')).documents).toHaveLength(1)
503503
})
504504

505-
it.each(['forbidden', 'notACalendarUser'])(
506-
'isolates explicit Calendar list access failures (%s) without claiming a disabled service',
507-
async (reason) => {
505+
it.each([{ reasons: ['forbidden'] }, { reasons: ['forbidden', 'notACalendarUser'] }])(
506+
'isolates explicit Calendar list access failures ($reasons) without claiming a disabled service',
507+
async ({ reasons }) => {
508508
listUserDocuments.mockRejectedValueOnce(
509-
new GoogleApiError('calendar.events.list', 403, [reason])
509+
new GoogleApiError('calendar.events.list', 403, reasons)
510510
)
511511
const first = await list(context(), undefined, CONFIG, 'google_calendar')
512512
expect(first.listingFailures?.samples[0]).toEqual({
513513
scope: 'alice@corp.com',
514514
operation: 'calendar.events.list',
515515
status: 403,
516-
reasons: [reason],
516+
reasons,
517517
})
518518
const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar')
519519
expect(second.documents[0].acl).toEqual(['u:bob@corp.com'])
520520
expect(second.reconciliationSafe).toBe(false)
521521
}
522522
)
523523

524+
it('leaves a user without the Calendar service to the scheduler instead of recording a failure', async () => {
525+
const error = new GoogleApiError('calendar.events.list', 403, ['notACalendarUser'])
526+
listUserDocuments.mockRejectedValueOnce(error)
527+
const ctx: Record<string, unknown> = context()
528+
await expect(list(ctx, undefined, CONFIG, 'google_calendar')).rejects.toBe(error)
529+
expect(ctx.reconciliationUnsafe).toBeUndefined()
530+
})
531+
524532
it.each([{ error: { code: 403 } }, { error: { code: 403, errors: [], details: [] } }])(
525533
'propagates a Calendar 403 without reason codes: %j',
526534
async (body) => {
@@ -626,10 +634,9 @@ describe('Google Workspace per-user central crawl', () => {
626634
})
627635

628636
it('bounds retained failure samples while counting every unavailable user', async () => {
629-
directory(
630-
Array.from({ length: 15 }, (_, index) =>
631-
USER(`user-${index}`, undefined, { isMailboxSetup: false })
632-
)
637+
directory(Array.from({ length: 15 }, (_, index) => USER(`user-${index}`)))
638+
listUserDocuments.mockRejectedValue(
639+
new GoogleApiError('gmail.threads.list', 400, ['failedPrecondition'])
633640
)
634641
let cursor: string | undefined
635642
let final
@@ -643,7 +650,7 @@ describe('Google Workspace per-user central crawl', () => {
643650
reconciliationSafe: false,
644651
})
645652
expect(final?.listingFailures?.samples).toHaveLength(10)
646-
expect(listUserDocuments).not.toHaveBeenCalled()
653+
expect(listUserDocuments).toHaveBeenCalledTimes(15)
647654
})
648655
})
649656

‎apps/sim/connectors/google-workspace/company-crawl.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,23 +161,39 @@ function ownerDocument(document: ExternalDocument, access: DelegatedUser): Exter
161161
return { ...document, acl: [`u:${access.user.email}`] }
162162
}
163163

164-
/** Isolates narrow user-list failures; delegation, known scope errors and quota errors still fail. */
164+
/**
165+
* Calendar answers an account without the Calendar service with exactly this reason. That is a
166+
* standing property of the account, not a listing failure, so the user scheduler skips it.
167+
*/
168+
export function isGoogleWorkspaceServiceNotEnabled(error: unknown): boolean {
169+
return (
170+
error instanceof GoogleApiError &&
171+
error.reasonsComplete &&
172+
error.status === 403 &&
173+
error.diagnostic?.operation === 'calendar.events.list' &&
174+
error.diagnostic.reasons.length === 1 &&
175+
error.diagnostic.reasons[0] === 'notACalendarUser'
176+
)
177+
}
178+
179+
/** Isolates narrow user-list failures; a missing Calendar service propagates for the scheduler to skip. */
165180
function userListingFailure(
166181
error: unknown,
167182
provider: GoogleWorkspaceProvider
168183
): Omit<ExternalListingFailures['samples'][number], 'scope'> | null {
169184
if (!(error instanceof GoogleApiError) || !error.diagnostic || !error.reasonsComplete) return null
170185
const reasons = error.diagnostic.reasons
171186
const isolated =
172-
provider === 'gmail'
187+
!isGoogleWorkspaceServiceNotEnabled(error) &&
188+
(provider === 'gmail'
173189
? error.diagnostic.operation === 'gmail.threads.list' &&
174190
error.status === 400 &&
175191
reasons.length > 0 &&
176192
reasons.every((reason) => reason === 'failedPrecondition')
177193
: error.diagnostic.operation === 'calendar.events.list' &&
178194
error.status === 403 &&
179195
reasons.length > 0 &&
180-
reasons.every((reason) => reason === 'forbidden' || reason === 'notACalendarUser')
196+
reasons.every((reason) => reason === 'forbidden' || reason === 'notACalendarUser'))
181197
return isolated
182198
? { operation: error.diagnostic.operation, status: error.status, reasons: [...reasons] }
183199
: null
@@ -317,9 +333,8 @@ export async function listGoogleWorkspaceDocuments(
317333
})
318334
return emptyPage(advance())
319335
}
320-
if (provider === 'gmail' && user.isMailboxSetup === false) {
321-
return failedUser({ operation: 'directory.users.get', reasons: ['mailboxNotSetup'] })
322-
}
336+
/** Directory refresh stops scheduling users without a mailbox; a partition queued earlier just completes. */
337+
if (provider === 'gmail' && user.isMailboxSetup === false) return emptyPage(advance())
323338
const access: PageAccess = {
324339
provider,
325340
user,

‎apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts‎

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ const document: ExternalDocument = {
3636
contentHash: 'hash',
3737
mimeType: 'text/plain',
3838
}
39-
function user(id: string): GoogleWorkspaceUser {
40-
return { id, email: `${id}@fixture.test`, customerId: 'customer', active: true }
39+
function user(id: string, extra: Partial<GoogleWorkspaceUser> = {}): GoogleWorkspaceUser {
40+
return { id, email: `${id}@fixture.test`, customerId: 'customer', active: true, ...extra }
4141
}
4242
interface FakeWork extends ConnectorPartitionWorkItem<GoogleWorkspaceUser> {
4343
complete: boolean
@@ -227,18 +227,19 @@ describe('durable Google company user scheduling', () => {
227227
})
228228

229229
it.each([
230-
['google_calendar', []],
231-
['google_calendar', ['notACalendarUser']],
232-
['google_drive', []],
230+
['google_calendar', [], false],
231+
['google_calendar', ['forbidden'], true],
232+
['google_calendar', ['notACalendarUser'], false],
233+
['google_drive', [], false],
233234
] as const)(
234-
'retains a failed %s user (%j) and continues other users',
235-
async (provider, reasons) => {
235+
'retains a failed %s user (%j, reasons complete: %s) and continues other users',
236+
async (provider, reasons, reasonsComplete) => {
236237
mocks.directory.mockResolvedValue({ users: [user('a'), user('z')] })
237238
const f = fixture(provider)
238239
f.list.mockRejectedValueOnce(
239240
provider === 'google_drive'
240241
? new GoogleDriveApiError(403, [], 'drive.files.list', false)
241-
: new GoogleApiError('calendar.events.list', 403, reasons, reasons.length > 0)
242+
: new GoogleApiError('calendar.events.list', 403, reasons, reasonsComplete)
242243
)
243244
await f.step(4)
244245
expect(f.rows.get('a:content')).toMatchObject({
@@ -273,9 +274,9 @@ describe('durable Google company user scheduling', () => {
273274
}
274275
)
275276

276-
it('continues past unavailable Calendar users without the unresolved-error pause and retries them later', async () => {
277-
mocks.directory.mockResolvedValue({ users: ['a', 'b', 'c', 'z'].map(user) })
278-
const f = fixture()
277+
it('completes Calendar users without the service so the listing stays reconcilable, re-probing them at the Directory refresh', async () => {
278+
mocks.directory.mockResolvedValue({ users: ['a', 'b', 'c', 'z'].map((id) => user(id)) })
279+
const f = fixture('google_calendar', 15)
279280
const listUserDocuments = vi.fn<ConnectorConfig['listDocuments']>(
280281
async (_token, _config, _cursor, ctx) => ({
281282
documents: [{ ...document, externalId: memberDocumentId('event', ctx) }],
@@ -302,27 +303,79 @@ describe('durable Google company user scheduling', () => {
302303
})
303304
)
304305

305-
await f.step(5)
306+
await f.step(10)
306307

307-
expect(f.rows.get('z:content')?.complete).toBe(true)
308-
expect(f.saved()).toMatchObject({ complete: false, unsafe: true, resumeAt: null })
309308
for (const id of ['a', 'b', 'c']) {
310309
expect(f.rows.get(`${id}:content`)).toMatchObject({
311-
complete: false,
312-
attempts: 1,
310+
complete: true,
311+
attempts: 0,
313312
retryAt: new Date('2026-09-17T01:00:00Z'),
314-
failure: { status: 403, reasons: ['notACalendarUser'] },
315313
})
314+
expect(f.rows.get(`${id}:content`)?.failure).toBeUndefined()
316315
}
316+
expect(f.rows.get('z:content')).toMatchObject({
317+
complete: true,
318+
retryAt: new Date('2026-09-17T00:15:00Z'),
319+
})
320+
expect(f.saved()).toMatchObject({ complete: true, unsafe: false, listingFailures: null })
321+
expect(listUserDocuments).toHaveBeenCalledTimes(4)
322+
})
317323

318-
f.advance(60 * 60 * 1000)
319-
f.restart()
320-
await f.step(4)
324+
it('refreshes permissions for a user without the Calendar service at the permission cadence', async () => {
325+
mocks.directory.mockResolvedValue({ users: [user('a'), user('z')] })
326+
const f = fixture()
327+
f.list.mockResolvedValue({ documents: [document], hasMore: true, nextCursor: 'next-page' })
328+
await f.step(2)
329+
f.advance(13 * 60 * 60 * 1000)
330+
f.list.mockRejectedValueOnce(
331+
new GoogleApiError('calendar.events.list', 403, ['notACalendarUser'])
332+
)
333+
await f.step(2)
334+
expect(mocks.directory).toHaveBeenCalledTimes(2)
335+
expect(f.rows.get('a:permissions')).toMatchObject({
336+
attempts: 0,
337+
retryAt: new Date('2026-09-18T01:00:00Z'),
338+
})
339+
expect(f.rows.get('a:permissions')?.failure).toBeUndefined()
340+
expect(f.rows.get('a:permissions')?.cursor).toBeUndefined()
341+
expect(f.saved()).toMatchObject({ unsafe: false, listingFailures: null })
342+
})
321343

322-
for (const id of ['a', 'b', 'c']) {
323-
expect(f.rows.get(`${id}:content`)).toMatchObject({ complete: true, attempts: 0 })
324-
expect(f.rows.get(`${id}:content`)?.failure).toBeUndefined()
344+
it.each([
345+
['gmail', false],
346+
['google_calendar', true],
347+
] as const)(
348+
'for %s, schedules a Directory user without a mailbox: %s',
349+
async (provider, scheduled) => {
350+
mocks.directory.mockResolvedValue({
351+
users: [user('a', { isMailboxSetup: false }), user('z')],
352+
})
353+
const f = fixture(provider)
354+
await f.step(4)
355+
expect(f.rows.has('a:content')).toBe(scheduled)
356+
expect(f.rows.get('z:content')?.complete).toBe(true)
357+
expect(f.saved()).toMatchObject({ unsafe: false, listingFailures: null })
325358
}
359+
)
360+
361+
it('picks up a Gmail user on the Directory refresh after their mailbox is provisioned', async () => {
362+
mocks.directory
363+
.mockResolvedValueOnce({ users: [user('a', { isMailboxSetup: false }), user('z')] })
364+
.mockResolvedValue({ users: [user('a', { isMailboxSetup: true }), user('z')] })
365+
const f = fixture('gmail')
366+
let page = 0
367+
f.list.mockImplementation(async () => ({
368+
documents: [document],
369+
hasMore: true,
370+
nextCursor: `page-${++page}`,
371+
}))
372+
await f.step(3)
373+
expect(f.rows.has('a:content')).toBe(false)
374+
f.advance(61 * 60_000)
375+
f.restart()
376+
await f.step(2)
377+
expect(mocks.directory).toHaveBeenCalledTimes(2)
378+
expect(f.rows.has('a:content')).toBe(true)
326379
})
327380

328381
it('bounds a run of unresolved user errors rather than marking the tenant complete', async () => {
@@ -457,8 +510,9 @@ describe('durable Google company user scheduling', () => {
457510
samples: [
458511
{
459512
scope: 'a@fixture.test',
460-
operation: 'directory.users.get',
461-
reasons: ['mailboxNotSetup'],
513+
operation: 'gmail.threads.list',
514+
status: 400,
515+
reasons: ['failedPrecondition'],
462516
},
463517
],
464518
},

‎apps/sim/lib/knowledge/connectors/google-company-scheduler.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import type {
66
import { googleDriveCompanyCursorAdapter } from '@/connectors/google-drive/company-crawl'
77
import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors'
88
import { GoogleApiError } from '@/connectors/google-workspace/api-errors'
9-
import { googleWorkspaceCompanyCursorAdapter } from '@/connectors/google-workspace/company-crawl'
9+
import {
10+
googleWorkspaceCompanyCursorAdapter,
11+
isGoogleWorkspaceServiceNotEnabled,
12+
} from '@/connectors/google-workspace/company-crawl'
1013
import type {
1114
GoogleCompanyCursorAdapter,
1215
GoogleCompanyUserWork,
@@ -183,8 +186,12 @@ export function createGoogleCompanyScheduler(input: {
183186
nextCursor: nextCursor({ ...state, directoryCursor: undefined }, {}),
184187
}
185188
}
189+
/** A user without a mailbox is out of scope like an inactive one until a later refresh sees it. */
186190
const users = page.users.filter(
187-
(user) => user.active && (!selected.length || selected.includes(user.email))
191+
(user) =>
192+
user.active &&
193+
(input.provider !== 'gmail' || user.isMailboxSetup !== false) &&
194+
(!selected.length || selected.includes(user.email))
188195
)
189196
return {
190197
documents: [],
@@ -296,6 +303,29 @@ export function createGoogleCompanyScheduler(input: {
296303
: {}),
297304
}
298305
}
306+
/** A user without the service completes cleanly and is re-probed no sooner than the Directory refresh. */
307+
const skipped = (): ExternalDocumentList => ({
308+
documents: [],
309+
currentCursor,
310+
hasMore: true,
311+
nextCursor: nextCursor(next, {
312+
update: {
313+
partitionKey: work.partitionKey,
314+
kind: work.kind,
315+
cursor: null,
316+
completed: true,
317+
attempts: 0,
318+
failure: null,
319+
retryAt: new Date(
320+
now().getTime() +
321+
(work.kind === 'permissions'
322+
? GOOGLE_COMPANY_PERMISSION_REFRESH_MS
323+
: Math.max(DIRECTORY_REFRESH_MS, input.syncIntervalMinutes * 60_000))
324+
),
325+
...(work.kind === 'permissions' ? { permissionStartedAt: null } : {}),
326+
},
327+
}),
328+
})
299329
let page: ExternalDocumentList
300330
try {
301331
page = await input.listDocuments(
@@ -312,6 +342,7 @@ export function createGoogleCompanyScheduler(input: {
312342
false,
313343
true
314344
)
345+
if (isGoogleWorkspaceServiceNotEnabled(error)) return skipped()
315346
const failure = deferredUserFailure(error, work.context)
316347
if (!failure) throw error
317348
return failed(failure, true)

0 commit comments

Comments
 (0)