Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/search/google-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Sim indexes event titles, descriptions, times, locations, and the selected atten

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).

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.
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 an explicit `forbidden` reason and no other reasons, 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. A `403` without a reason stops the crawl because its cause is unknown. 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.

## Troubleshooting

Expand Down
29 changes: 26 additions & 3 deletions apps/sim/connectors/google-calendar/company-crawl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,26 @@ describe('Google Calendar company crawl', () => {
}
)

it('continues another user after an unclassified list access denial and retains its diagnostic', async () => {
fetchMock.mockResolvedValueOnce(response({ error: { code: 403 } }, 403))
it.each([{ error: { code: 403 } }, { error: { code: 403, errors: [], details: [] } }])(
'propagates an unclassified list access denial and retains its diagnostic: %j',
async (body) => {
fetchMock.mockResolvedValueOnce(response(body, 403))
const syncContext = context()
await expect(
googleCalendarConnector.listDocuments('directory-token', {}, undefined, syncContext)
).rejects.toMatchObject({
status: 403,
diagnostic: { operation: 'calendar.events.list', reasons: [] },
})
expect(fetchMock).toHaveBeenCalledOnce()
expect(syncContext.getDelegatedAccessToken.mock.calls).toEqual([[ALICE.email]])
}
)

it('continues another user after an explicit forbidden denial and retains its diagnostic', async () => {
fetchMock.mockResolvedValueOnce(
response({ error: { code: 403, errors: [{ reason: 'forbidden' }] } }, 403)
)
const first = await googleCalendarConnector.listDocuments(
'directory-token',
{},
Expand All @@ -369,7 +387,12 @@ describe('Google Calendar company crawl', () => {
listingFailures: {
count: 1,
samples: [
{ scope: ALICE.email, operation: 'calendar.events.list', status: 403, reasons: [] },
{
scope: ALICE.email,
operation: 'calendar.events.list',
status: 403,
reasons: ['forbidden'],
},
],
},
})
Expand Down
45 changes: 28 additions & 17 deletions apps/sim/connectors/google-workspace/company-crawl.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GoogleApiError } from '@/connectors/google-workspace/api-errors'
import { GoogleApiError, readGoogleApiError } from '@/connectors/google-workspace/api-errors'
import {
getGoogleWorkspaceDocument,
InvalidGoogleWorkspaceCursor,
Expand Down Expand Up @@ -501,26 +501,37 @@ describe('Google Workspace per-user central crawl', () => {
expect((await list(context(), undefined, CONFIG, 'google_calendar')).documents).toHaveLength(1)
})

it.each([{ reasons: [] }, { reasons: ['forbidden'] }])(
'isolates Calendar list access failures without claiming a disabled service (%j)',
async ({ reasons }) => {
listUserDocuments.mockRejectedValueOnce(
new GoogleApiError('calendar.events.list', 403, reasons)
)
const first = await list(context(), undefined, CONFIG, 'google_calendar')
expect(first.listingFailures?.samples[0]).toEqual({
scope: 'alice@corp.com',
operation: 'calendar.events.list',
status: 403,
reasons,
})
const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar')
expect(second.documents[0].acl).toEqual(['u:bob@corp.com'])
expect(second.reconciliationSafe).toBe(false)
it('isolates explicit Calendar list access failures without claiming a disabled service', async () => {
listUserDocuments.mockRejectedValueOnce(
new GoogleApiError('calendar.events.list', 403, ['forbidden'])
)
const first = await list(context(), undefined, CONFIG, 'google_calendar')
expect(first.listingFailures?.samples[0]).toEqual({
scope: 'alice@corp.com',
operation: 'calendar.events.list',
status: 403,
reasons: ['forbidden'],
})
const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar')
expect(second.documents[0].acl).toEqual(['u:bob@corp.com'])
expect(second.reconciliationSafe).toBe(false)
})

it.each([{ error: { code: 403 } }, { error: { code: 403, errors: [], details: [] } }])(
'propagates a Calendar 403 without reason codes: %j',
async (body) => {
const error = await readGoogleApiError(json(body, 403), 'calendar.events.list')
listUserDocuments.mockRejectedValueOnce(error)
const ctx: Record<string, unknown> = context()

await expect(list(ctx, undefined, CONFIG, 'google_calendar')).rejects.toBe(error)
expect(ctx.reconciliationUnsafe).toBeUndefined()
expect(listUserDocuments).toHaveBeenCalledOnce()
}
)

it.each([
[403, []],
[403, ['rateLimitExceeded']],
[403, ['userRateLimitExceeded']],
[403, ['quotaExceeded']],
Expand Down
1 change: 1 addition & 0 deletions apps/sim/connectors/google-workspace/company-crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ function userListingFailure(
reasons.every((reason) => reason === 'failedPrecondition')
: error.diagnostic.operation === 'calendar.events.list' &&
error.status === 403 &&
reasons.length > 0 &&
reasons.every((reason) => reason === 'forbidden')
return isolated
? { operation: error.diagnostic.operation, status: error.status, reasons: [...reasons] }
Expand Down
Loading