Skip to content

Commit 6be9008

Browse files
authored
fix(search): retain permission sync failures and clarify progress (#7897)
* fix(search): retain permission sync failures and clarify progress * docs(search): explain permission warnings and sync continuation * fix(search): report rejected permission grants as incomplete * fix(search): distinguish member failures from continuation * fix(testing): include member sync failure columns in schema mocks * fix(search): retain central indexing dispatch warnings
1 parent 9cfa049 commit 6be9008

27 files changed

Lines changed: 27809 additions & 85 deletions

‎apps/docs/content/docs/search/confluence.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ Open **Settings → Sources → Confluence**, then a source's **Documents**, **S
132132

133133
Syncing runs automatically. Admins can use **Sync now** for an immediate update, **Pause syncing** to stop scheduled syncs, or **Resume syncing** to restart them. Successful manual syncs have a one-minute cooldown; failed syncs can be retried immediately.
134134

135+
In **Sync history**, **Continuing** means a healthy listing needs another batch. **Partial** means the sync did not fully succeed; read the accompanying notice. If permissions could not be verified, the last successful sync time stays unchanged and documents without verified access remain hidden from Search.
136+
135137
## Troubleshooting
136138

137139
| Problem | What to check |
@@ -140,6 +142,7 @@ Syncing runs automatically. Admins can use **Sync now** for an immediate update,
140142
| Space picker is empty or fails | Check the domain, account's space access, and `read:space:confluence` scope. Manual space keys are also supported. |
141143
| Service-account validation fails | Check token expiry, site, Confluence app access, and the full scope list above, including `read:confluence-user`. |
142144
| Content syncs but Search is empty | Connect your personal Confluence identity. Check permission/directory sync errors and group-read scopes. |
145+
| **Some permissions could not be verified** | Open the source's **Sync history**. Check the service account's space, page, and directory access. If access is correct and the warning persists, ask your operator to inspect the connector run's permission errors. Do not broaden sharing to clear the warning. |
143146
| A new page, blog post, or label is missing | Confluence search can take time to update. Once the content appears in Confluence search with the selected label, sync again. |
144147
| A restricted page is missing | Both your account and the crawling account need access to the page and its ancestors. |
145148
| Embedded content is missing | Index the referenced page separately; remote macro output is excluded. |

‎apps/docs/content/docs/search/jira.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ After an admin approves Jira, a teammate can select **Connect** on the Jira row
9090

9191
Admins open **Settings → Sources → Jira**, then a connection’s **Documents**, **Settings**, or **Sync history**. Metadata tags include issue type, status, priority, labels, assignee, and last updated.
9292

93+
In **Sync history**, **Continuing** means a healthy listing needs another batch. **Partial** means some work did not succeed; the account counts and notice identify what needs attention.
94+
9395
Teammates use the configured site and projects without entering them again. **Additional connection required** means another configured selection needs authorization; select **Connect**. **Reconnect** is for an account whose authorization needs renewing.
9496

9597
Invite teammates through **Settings → Members → Invite** or SSO, then have them connect Jira through **Integrations**. **Settings → Sources → People**, filter by **Jira**, then select **Request connections** only requests a provider connection; it does not invite people to the organization.

‎apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('organization source status labels', () => {
2929
['sync_failed', 'Sync failed'],
3030
['account_sync_incomplete', 'Some accounts are not up to date'],
3131
['document_indexing_failed', 'Some documents failed to index'],
32+
['permission_sync_incomplete', 'Some permissions could not be verified'],
3233
] as const)('describes %s and keeps concurrent recovery visible', (issue, label) => {
3334
expect(organizationSearchStatusLabel({ ...provider, status: 'needs_attention', issue })).toBe(
3435
label

‎apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ export function organizationSearchStatusLabel(provider: OrganizationSearchProvid
1616
const error =
1717
provider.issue === 'account_sync_incomplete'
1818
? 'Some accounts are not up to date'
19-
: provider.issue === 'document_indexing_failed'
20-
? 'Some documents failed to index'
21-
: 'Sync failed'
19+
: provider.issue === 'permission_sync_incomplete'
20+
? 'Some permissions could not be verified'
21+
: provider.issue === 'document_indexing_failed'
22+
? 'Some documents failed to index'
23+
: 'Sync failed'
2224
return provider.isSyncing ? `Indexing · ${error}` : error
2325
}
2426
return STATUS_LABELS[provider.status]

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history.tsx‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,15 @@ export function ConnectorSyncHistory({
6262
)
6363
}
6464

65-
type SyncLogState = 'running' | 'interrupted' | 'failed' | 'completed' | 'partial'
65+
type SyncLogState = 'running' | 'interrupted' | 'failed' | 'completed' | 'partial' | 'continuing'
6666

6767
const SYNC_LOG_LABELS: Record<SyncLogState, string> = {
6868
running: 'In progress…',
6969
interrupted: 'Interrupted',
7070
failed: 'Failed',
7171
completed: 'Completed',
7272
partial: 'Partial',
73+
continuing: 'Continuing',
7374
}
7475

7576
/** Reclaimed stale locks leave started log rows behind; both views use the engine's own TTL. */
@@ -92,9 +93,10 @@ interface SyncHistoryRowProps {
9293
startedAt: string
9394
state: SyncLogState
9495
description?: string
96+
notice?: string | null
9597
}
9698

97-
function SyncHistoryRow({ startedAt, state, description }: SyncHistoryRowProps) {
99+
function SyncHistoryRow({ startedAt, state, description, notice }: SyncHistoryRowProps) {
98100
return (
99101
<SettingsResourceRow
100102
title={
@@ -103,7 +105,7 @@ function SyncHistoryRow({ startedAt, state, description }: SyncHistoryRowProps)
103105
{state === 'completed' && <span className='sr-only'> · {SYNC_LOG_LABELS[state]}</span>}
104106
</time>
105107
}
106-
description={description}
108+
description={[description, notice].filter(Boolean).join(' · ') || undefined}
107109
badge={
108110
state === 'completed' ? undefined : (
109111
<span
@@ -135,7 +137,14 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
135137
return (
136138
<div className={RESOURCE_LIST_STACK}>
137139
{logs.map((log) => {
138-
const state = getSyncLogState(log, CONNECTOR_SYNC_STALE_LOCK_TTL_MS, now)
140+
const continuing =
141+
log.status === 'partial' &&
142+
log.listedCount === null &&
143+
log.docsFailed === 0 &&
144+
!log.errorMessage
145+
const state = continuing
146+
? 'continuing'
147+
: getSyncLogState(log, CONNECTOR_SYNC_STALE_LOCK_TTL_MS, now)
139148
const changes = [
140149
log.docsAdded > 0 && `${log.docsAdded} added`,
141150
log.docsUpdated > 0 && `${log.docsUpdated} updated`,
@@ -150,11 +159,13 @@ export function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
150159
key={log.id}
151160
startedAt={log.startedAt}
152161
state={state}
162+
notice={state === 'failed' ? undefined : log.errorMessage}
153163
description={
154164
state === 'failed'
155165
? (log.errorMessage ?? undefined)
156-
: state === 'completed' || state === 'partial'
157-
? changes || 'No changes'
166+
: state === 'completed' || state === 'partial' || state === 'continuing'
167+
? changes ||
168+
(state === 'continuing' || log.errorMessage ? undefined : 'No changes')
158169
: undefined
159170
}
160171
/>
@@ -193,17 +204,29 @@ function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps)
193204
<SettingsEmptyState variant='inline'>No member sync history yet.</SettingsEmptyState>
194205
) : (
195206
logs.map((log) => {
196-
const state = getSyncLogState(log, MEMBER_SYNC_STALE_LOCK_TTL_MS, now)
207+
const continuing =
208+
log.status === 'partial' &&
209+
log.membersIncomplete > 0 &&
210+
log.membersFailed === 0 &&
211+
log.docsFailed === 0 &&
212+
log.processingDispatchFailed === 0 &&
213+
!log.errorMessage
214+
const state = continuing
215+
? 'continuing'
216+
: getSyncLogState(log, MEMBER_SYNC_STALE_LOCK_TTL_MS, now)
197217
const changes = [
198218
log.docsAdded > 0 && `${log.docsAdded} added`,
199219
log.docsUpdated > 0 && `${log.docsUpdated} updated`,
200220
log.docsTombstoned + log.docsPurged > 0 &&
201221
`${log.docsTombstoned + log.docsPurged} deleted`,
222+
(log.docsFailed ?? 0) > 0 && `${log.docsFailed} failed`,
223+
(log.processingDispatchFailed ?? 0) > 0 &&
224+
`${log.processingDispatchFailed} failed to queue`,
202225
]
203226
.filter(Boolean)
204227
.join(' · ')
205228
const description = [
206-
changes || 'No changes',
229+
changes || (continuing || log.errorMessage ? undefined : 'No changes'),
207230
log.membersFailed > 0 &&
208231
`${log.membersFailed} ${log.membersFailed === 1 ? 'account' : 'accounts'} failed`,
209232
log.membersIncomplete > 0 &&
@@ -216,10 +239,11 @@ function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps)
216239
key={log.id}
217240
startedAt={log.startedAt}
218241
state={state}
242+
notice={state === 'failed' ? undefined : log.errorMessage}
219243
description={
220244
state === 'failed'
221245
? (log.errorMessage ?? undefined)
222-
: state === 'completed' || state === 'partial'
246+
: state === 'completed' || state === 'partial' || state === 'continuing'
223247
? description
224248
: undefined
225249
}

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,41 @@ describe('shared connector sync history', () => {
10631063
}
10641064
)
10651065

1066+
it.each([
1067+
{ docsFailed: 0, processingDispatchFailed: 0, continuing: true },
1068+
{ docsFailed: 1, processingDispatchFailed: 0, continuing: false },
1069+
{ docsFailed: 0, processingDispatchFailed: 1, continuing: false },
1070+
{ docsFailed: null, processingDispatchFailed: null, continuing: false },
1071+
{ docsFailed: undefined, processingDispatchFailed: undefined, continuing: false },
1072+
])('requires known healthy member counters for continuation: %j', (fields) => {
1073+
lifecycle.detail.current = {
1074+
memberSyncLogs: [
1075+
{
1076+
...makeLog({ status: 'partial' }),
1077+
membersCompleted: 1,
1078+
membersIncomplete: 1,
1079+
membersFailed: 0,
1080+
docsFailed: fields.docsFailed,
1081+
processingDispatchFailed: fields.processingDispatchFailed,
1082+
docsTombstoned: 0,
1083+
docsPurged: 0,
1084+
},
1085+
],
1086+
}
1087+
const container = renderComponent(
1088+
<ConnectorSyncHistory
1089+
connector={makeConnector({ accessMode: 'members' })}
1090+
knowledgeBaseId='knowledge-1'
1091+
/>
1092+
)
1093+
expect(container.textContent).toContain(fields.continuing ? 'Continuing' : 'Partial')
1094+
expect(container.textContent).not.toContain(fields.continuing ? 'Partial' : 'Continuing')
1095+
if (fields.continuing) expect(container.textContent).not.toContain('No changes')
1096+
if (fields.docsFailed) expect(container.textContent).toContain('1 failed')
1097+
if (fields.processingDispatchFailed)
1098+
expect(container.textContent).toContain('1 failed to queue')
1099+
})
1100+
10661101
it('loads the member engine history rather than the content history', () => {
10671102
lifecycle.detail.current = {
10681103
syncLogs: [makeLog({ status: 'completed', docsAdded: 999 })],
@@ -1129,13 +1164,37 @@ describe('SyncHistory', () => {
11291164
expect(container.textContent).not.toContain('No changes')
11301165
})
11311166

1132-
it('renders a continued listing as partial with the work already completed', () => {
1133-
const container = render(makeLog({ status: 'partial', docsAdded: 3 }))
1134-
expect(container.textContent).toContain('Partial')
1167+
it('distinguishes a continued listing from a partial failure', () => {
1168+
const container = render(makeLog({ status: 'partial', docsAdded: 3, listedCount: null }))
1169+
expect(container.textContent).toContain('Continuing')
11351170
expect(container.textContent).toContain('3 added')
11361171
expect(container.textContent).not.toContain('In progress…')
11371172
})
11381173

1174+
it('keeps permission failures visible even when document processing succeeded', () => {
1175+
const container = render(
1176+
makeLog({
1177+
status: 'partial',
1178+
listedCount: 4,
1179+
errorMessage: 'Some document permissions could not be verified.',
1180+
})
1181+
)
1182+
expect(container.textContent).toContain('Some document permissions could not be verified.')
1183+
expect(container.textContent).toContain('Partial')
1184+
expect(container.textContent).not.toContain('No changes')
1185+
expect(container.textContent).not.toContain('Continuing')
1186+
})
1187+
1188+
it.each([
1189+
{ docsFailed: 1, listedCount: null },
1190+
{ docsFailed: 0, listedCount: 4 },
1191+
{ docsFailed: 0 },
1192+
])('does not label failed, finished, or legacy partial logs as continuing: %j', (fields) => {
1193+
const container = render(makeLog({ status: 'partial', ...fields }))
1194+
expect(container.textContent).toContain('Partial')
1195+
expect(container.textContent).not.toContain('Continuing')
1196+
})
1197+
11391198
it('keeps completion accessible without repeating decorative status on every row', () => {
11401199
const log = makeLog({ status: 'completed', docsAdded: 3 })
11411200
const container = render(log)

‎apps/sim/connectors/confluence/permissions.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,59 @@ describe('listSpaceReadPrincipals', () => {
284284

285285
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow('403')
286286
})
287+
288+
it.each([
289+
'/wiki/api/v2/spaces/1/permissions?cursor=next',
290+
'/wiki/api/v2/spaces/1/permissions?limit=250',
291+
])(
292+
'rejects a repeated or missing cursor without publishing partial permissions: %s',
293+
async (next) => {
294+
mockFetch
295+
.mockResolvedValueOnce(jsonResponse({ results: [], _links: { next: '?cursor=next' } }))
296+
.mockResolvedValueOnce(jsonResponse({ results: [], _links: { next } }))
297+
298+
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow(
299+
'invalid or repeated space permissions continuation'
300+
)
301+
expect(mockFetch).toHaveBeenCalledTimes(2)
302+
}
303+
)
304+
305+
it('rejects a cursor cycle rather than making a hundred repeated requests', async () => {
306+
for (const cursor of ['first', 'second', 'first']) {
307+
mockFetch.mockResolvedValueOnce(
308+
jsonResponse({ results: [], _links: { next: `?cursor=${cursor}` } })
309+
)
310+
}
311+
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow('repeated')
312+
expect(mockFetch).toHaveBeenCalledTimes(3)
313+
})
314+
315+
it('rejects a malformed collection instead of treating it as a verified empty grant', async () => {
316+
mockFetch.mockResolvedValueOnce(jsonResponse({}))
317+
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow(
318+
'invalid space permissions'
319+
)
320+
})
321+
322+
it('keeps the request bound for a provider that keeps issuing distinct continuations', async () => {
323+
let page = 0
324+
mockFetch.mockImplementation(async () =>
325+
jsonResponse({
326+
results: [
327+
{
328+
principal: { type: 'user', id: 'reader' },
329+
operation: { key: 'read', targetType: 'space' },
330+
},
331+
],
332+
_links: { next: `?cursor=${++page}` },
333+
})
334+
)
335+
await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow(
336+
'exceeded 100 pages (100 entries)'
337+
)
338+
expect(mockFetch).toHaveBeenCalledTimes(100)
339+
})
287340
})
288341

289342
describe('getReadRestriction', () => {

‎apps/sim/connectors/confluence/permissions.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ async function getJson<T>(
7878
*/
7979
async function drainV2<T>(url: string, accessToken: string, what: string): Promise<T[]> {
8080
const items: T[] = []
81+
const cursors = new Set<string>()
8182
let cursor: string | undefined
8283
for (let page = 0; page < MAX_PAGES; page += 1) {
8384
const query = new URLSearchParams({ limit: String(PAGE_SIZE) })
@@ -86,11 +87,19 @@ async function drainV2<T>(url: string, accessToken: string, what: string): Promi
8687
`${url}?${query.toString()}`,
8788
accessToken
8889
)
89-
items.push(...(body.results ?? []))
90-
cursor = extractCursor(body._links?.next)
91-
if (!cursor) return items
90+
if (!Array.isArray(body.results)) {
91+
throw new Error(`Confluence returned invalid ${what}`)
92+
}
93+
items.push(...body.results)
94+
const next = body._links?.next
95+
if (!next) return items
96+
cursor = extractCursor(next)
97+
if (!cursor || cursors.has(cursor)) {
98+
throw new Error(`Confluence returned an invalid or repeated ${what} continuation`)
99+
}
100+
cursors.add(cursor)
92101
}
93-
throw new Error(`Confluence ${what} exceeded ${MAX_PAGES} pages`)
102+
throw new Error(`Confluence ${what} exceeded ${MAX_PAGES} pages (${items.length} entries)`)
94103
}
95104

96105
/**

‎apps/sim/hooks/queries/kb/connectors.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
} from '@/lib/api/contracts/knowledge'
4646
import {
4747
type ConnectorDetailData,
48+
type OrganizationSearchOverview,
4849
readSearchIndexContract,
4950
} from '@/lib/api/contracts/knowledge/connectors'
5051
import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants'
@@ -55,6 +56,7 @@ import {
5556
useConnectorDetail,
5657
useConnectorDocuments,
5758
useConnectorList,
59+
useOrganizationSearchOverview,
5860
useSearchIndex,
5961
useSearchSources,
6062
useTriggerSync,
@@ -159,6 +161,36 @@ describe('isConnectorSyncingOrPending', () => {
159161
)
160162
})
161163

164+
describe('organization overview polling', () => {
165+
it.each([
166+
{ isSyncing: true, hasPendingSync: false, polling: true },
167+
{ isSyncing: false, hasPendingSync: true, polling: true },
168+
{ isSyncing: false, hasPendingSync: false, polling: false },
169+
{ isSyncing: false, hasPendingSync: undefined, polling: false },
170+
])('polls unfinished work across worker handoffs: %j', ({ polling, ...state }) => {
171+
useOrganizationSearchOverview('organization-1')
172+
const { refetchInterval } = capturedQueryOptions<OrganizationSearchOverview>()
173+
const interval = refetchInterval({
174+
state: {
175+
data: {
176+
providers: [
177+
{
178+
connectorType: 'confluence',
179+
approved: true,
180+
sourceCount: 1,
181+
status: 'active',
182+
issue: null,
183+
...state,
184+
},
185+
],
186+
},
187+
},
188+
})
189+
if (polling) expect(interval).toBeGreaterThan(0)
190+
else expect(interval).toBe(false)
191+
})
192+
})
193+
162194
describe('useConnectorList polling', () => {
163195
beforeEach(() => {
164196
vi.clearAllMocks()

0 commit comments

Comments
 (0)