Skip to content

Commit 7e975e7

Browse files
authored
fix(confluence): index mirrored/included page content via rendered view format (#5746)
* fix(confluence): index mirrored/included page content via rendered view format The KB connector fetched page bodies as body-format=storage, which only carries unexpanded macro references (Include Page / Excerpt Include). Those 'mirrored' articles were stripped to empty content by htmlToPlainText and never synced. Switch getDocument to body-format=view (supported on the v2 single-item page/blogpost GET) so built-in include/excerpt macros render inline and the included text is indexed. * fix(confluence): invalidate existing doc hashes on representation change The version-based contentHash meant already-synced mirrored documents (with stale empty content) classified as 'unchanged' and never re-hydrated with the new rendered view content. Embed a body-representation marker in the hash so a representation change invalidates every previously-synced Confluence document, forcing a one-time re-hydration that picks up the expanded include/excerpt text. * feat(connectors): full resync re-hydrates rendered content (transclusions) Version-based change detection can't see when a Confluence page's rendered view changes because an *included* page was edited (the container's version doesn't bump). Add a 'Full resync' path so that drift can be recovered: - ConnectorMeta.rehydrateOnFullSync flag (set for Confluence) - on fullSync, classifyExternalDoc promotes unchanged deferred docs to update and the hydration guard re-indexes unconditionally, so rendered content is refreshed - fullSync threaded through the manual sync contract (query param), route, and hook - 'Sync now' / 'Full resync' dropdown on the connector card Incremental syncs stay hash-gated and cheap; only the deliberate full resync pays the re-index cost. * refactor(connectors): decouple rehydrate from fullSync deletion semantics The transclusion refresh only needs re-hydration, but reusing the fullSync flag also activated its deletion-cleanup semantics — which bypass three previously unreachable safety guards (empty-listing wipe, listingCapped, and the >50% mass-deletion threshold). Since fullSync had no caller before this PR, the new 'Full resync' button would have exposed all three to any KB editor. Introduce a dedicated 'rehydrate' request that ONLY forces re-hydration + re-index of already-synced docs. Listing and deletion reconciliation are identical to a normal sync (all safety guards stay armed). fullSync's cleanup semantics remain dormant and untouched. * refactor(connectors): tidy rehydrate flag + gate Full resync to supported connectors Cleanup/simplify pass over the connector changes: - use shared booleanQueryFlagSchema for the rehydrate query param (typed boolean at the boundary instead of a hand-rolled 'true'/'false' string enum) - move rehydrateOnFullSync onto the client-safe ConnectorMeta so the UI can gate on it - only Confluence (rehydrateOnFullSync) shows the Sync now / Full resync dropdown; every other connector keeps its original one-click sync button (Full resync is a no-op for them, and this restores the pre-change one-click UX) - wrap the sync trigger in a span so its tooltip still shows while disabled (cooldown) * fix(connectors): forward rehydrate flag through the Trigger.dev worker executeConnectorSyncJob (the production async sync path) destructured only fullSync from the payload and forwarded only fullSync to executeSync, silently dropping rehydrate. A manual Full resync would therefore never re-hydrate on the default Trigger.dev path. Forward rehydrate too. * fix(connectors): rehydrate forces a full listing so containers aren't omitted A rehydrate request set forceRehydrate but left listing incremental. For a connector that is both incremental and rehydrateOnFullSync, an unchanged container page that transcludes a changed page would be omitted from the incremental listing and never re-hydrated. Force a full (non-incremental) listing on rehydrate so every document is seen; deletion-safety guards stay armed (unlike fullSync). No-op for Confluence, which is already non-incremental.
1 parent d637cba commit 7e975e7

14 files changed

Lines changed: 313 additions & 42 deletions

File tree

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,50 @@ describe('Connector Manual Sync API Route', () => {
138138
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', {
139139
billingAttribution,
140140
requestId: 'test-req-id',
141+
rehydrate: false,
142+
})
143+
})
144+
145+
it('dispatches a full resync when rehydrate=true is set', async () => {
146+
const billingAttribution = {
147+
actorUserId: 'external-admin',
148+
workspaceId: 'ws-1',
149+
organizationId: null,
150+
billedAccountUserId: 'owner-1',
151+
billingEntity: { type: 'user' as const, id: 'owner-1' },
152+
billingPeriod: {
153+
start: '2026-07-01T00:00:00.000Z',
154+
end: '2026-08-01T00:00:00.000Z',
155+
},
156+
payerSubscription: null,
157+
}
158+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
159+
success: true,
160+
authType: 'session',
161+
userId: 'external-admin',
162+
userName: 'Test',
163+
userEmail: 'test@test.com',
164+
})
165+
mockCheckWriteAccess.mockResolvedValue({
166+
hasAccess: true,
167+
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
168+
})
169+
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
170+
mockResolveBillingAttribution.mockResolvedValue(billingAttribution)
171+
172+
const req = createMockRequest(
173+
'POST',
174+
undefined,
175+
{},
176+
'http://localhost:3000/api/knowledge/kb-123/connectors/conn-456/sync?rehydrate=true'
177+
)
178+
const response = await POST(req as never, { params: mockParams })
179+
180+
expect(response.status).toBe(200)
181+
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', {
182+
billingAttribution,
183+
requestId: 'test-req-id',
184+
rehydrate: true,
141185
})
142186
})
143187
})

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
2929
const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context)
3030
if (!parsed.success) return parsed.response
3131
const { id: knowledgeBaseId, connectorId } = parsed.data.params
32+
const { rehydrate } = parsed.data.query
3233

3334
try {
3435
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
@@ -81,7 +82,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
8182
workspaceId: kbWorkspaceId,
8283
})
8384

84-
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
85+
logger.info(
86+
`[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}`
87+
)
8588

8689
captureServerEvent(
8790
auth.userId,
@@ -109,12 +112,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
109112
knowledgeBaseName: writeCheck.knowledgeBase.name,
110113
connectorType: connectorRows[0].connectorType,
111114
connectorStatus: connectorRows[0].status,
112-
syncType: 'manual',
115+
syncType: rehydrate ? 'manual-rehydrate' : 'manual',
113116
},
114117
request,
115118
})
116119

117-
dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => {
120+
dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => {
118121
logger.error(
119122
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
120123
error

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

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
'use client'
22

33
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
4-
import { Badge, Button, Checkbox, ChipConfirmModal, cn, Loader, Tooltip } from '@sim/emcn'
4+
import {
5+
Badge,
6+
Button,
7+
Checkbox,
8+
ChipConfirmModal,
9+
cn,
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuItem,
13+
DropdownMenuTrigger,
14+
Loader,
15+
Tooltip,
16+
} from '@sim/emcn'
517
import { createLogger } from '@sim/logger'
618
import { format, formatDistanceToNow, isPast } from 'date-fns'
719
import {
@@ -115,14 +127,14 @@ export function ConnectorsSection({
115127
}, [])
116128

117129
const handleSync = useCallback(
118-
(connectorId: string) => {
130+
(connectorId: string, rehydrate = false) => {
119131
if (isSyncOnCooldown(connectorId)) return
120132

121133
syncTriggeredAt.current[connectorId] = Date.now()
122134
addToSet(setSyncingIds, connectorId)
123135

124136
triggerSync(
125-
{ knowledgeBaseId, connectorId },
137+
{ knowledgeBaseId, connectorId, rehydrate },
126138
{
127139
onSuccess: () => {
128140
setError(null)
@@ -214,7 +226,7 @@ export function ConnectorsSection({
214226
isSyncPending={syncingIds.has(connector.id)}
215227
isUpdating={updatingIds.has(connector.id)}
216228
syncCooldown={isSyncOnCooldown(connector.id)}
217-
onSync={() => handleSync(connector.id)}
229+
onSync={(rehydrate) => handleSync(connector.id, rehydrate)}
218230
onTogglePause={() => handleTogglePause(connector)}
219231
onEdit={() => setEditingConnector(connector)}
220232
onDelete={() => setDeleteTarget(connector.id)}
@@ -273,7 +285,7 @@ interface ConnectorCardProps {
273285
isSyncPending: boolean
274286
isUpdating: boolean
275287
syncCooldown: boolean
276-
onSync: () => void
288+
onSync: (rehydrate?: boolean) => void
277289
onEdit: () => void
278290
onTogglePause: () => void
279291
onDelete: () => void
@@ -327,6 +339,14 @@ function ConnectorCard({
327339
)
328340
const syncLogs = detail?.syncLogs ?? []
329341

342+
const canFullResync = Boolean(connectorDef?.rehydrateOnFullSync)
343+
const syncDisabled =
344+
connector.status === 'syncing' ||
345+
connector.status === 'disabled' ||
346+
isSyncPending ||
347+
syncCooldown
348+
const syncTooltip = syncCooldown ? 'Sync recently triggered' : canFullResync ? 'Sync' : 'Sync now'
349+
330350
return (
331351
<div
332352
className={cn(
@@ -409,28 +429,60 @@ function ConnectorCard({
409429
<div className='flex flex-shrink-0 items-center gap-0.5'>
410430
{canEdit && (
411431
<>
412-
<Tooltip.Root>
413-
<Tooltip.Trigger asChild>
414-
<Button
415-
variant='ghost'
416-
className={CONNECTOR_ACTION_BUTTON_CLASSES}
417-
onClick={onSync}
418-
disabled={
419-
connector.status === 'syncing' ||
420-
connector.status === 'disabled' ||
421-
isSyncPending ||
422-
syncCooldown
423-
}
424-
>
425-
<RefreshCw
426-
className={cn('size-3.5', connector.status === 'syncing' && 'animate-spin')}
427-
/>
428-
</Button>
429-
</Tooltip.Trigger>
430-
<Tooltip.Content>
431-
{syncCooldown ? 'Sync recently triggered' : 'Sync now'}
432-
</Tooltip.Content>
433-
</Tooltip.Root>
432+
{canFullResync ? (
433+
<DropdownMenu>
434+
<Tooltip.Root>
435+
<Tooltip.Trigger asChild>
436+
{/* span keeps the tooltip hoverable while the trigger button is disabled */}
437+
<span className='inline-flex'>
438+
<DropdownMenuTrigger asChild>
439+
<Button
440+
variant='ghost'
441+
aria-label='Sync options'
442+
className={CONNECTOR_ACTION_BUTTON_CLASSES}
443+
disabled={syncDisabled}
444+
>
445+
<RefreshCw
446+
className={cn(
447+
'size-3.5',
448+
connector.status === 'syncing' && 'animate-spin'
449+
)}
450+
/>
451+
</Button>
452+
</DropdownMenuTrigger>
453+
</span>
454+
</Tooltip.Trigger>
455+
<Tooltip.Content>{syncTooltip}</Tooltip.Content>
456+
</Tooltip.Root>
457+
<DropdownMenuContent align='end'>
458+
<DropdownMenuItem onSelect={() => onSync(false)}>Sync now</DropdownMenuItem>
459+
<DropdownMenuItem onSelect={() => onSync(true)}>Full resync</DropdownMenuItem>
460+
</DropdownMenuContent>
461+
</DropdownMenu>
462+
) : (
463+
<Tooltip.Root>
464+
<Tooltip.Trigger asChild>
465+
{/* span keeps the tooltip hoverable while the button is disabled */}
466+
<span className='inline-flex'>
467+
<Button
468+
variant='ghost'
469+
aria-label='Sync now'
470+
className={CONNECTOR_ACTION_BUTTON_CLASSES}
471+
disabled={syncDisabled}
472+
onClick={() => onSync(false)}
473+
>
474+
<RefreshCw
475+
className={cn(
476+
'size-3.5',
477+
connector.status === 'syncing' && 'animate-spin'
478+
)}
479+
/>
480+
</Button>
481+
</span>
482+
</Tooltip.Trigger>
483+
<Tooltip.Content>{syncTooltip}</Tooltip.Content>
484+
</Tooltip.Root>
485+
)}
434486

435487
<Tooltip.Root>
436488
<Tooltip.Trigger asChild>

apps/sim/background/knowledge-connector-sync.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,29 @@ describe('knowledge connector sync worker', () => {
7272
expect(mockExecuteSync).toHaveBeenCalledWith('connector-1', {
7373
billingAttribution: BILLING_ATTRIBUTION,
7474
fullSync: true,
75+
rehydrate: undefined,
76+
})
77+
})
78+
79+
it('forwards the rehydrate flag to the sync engine (async worker path)', async () => {
80+
mockAssertConnectorSyncPayload.mockReturnValue({
81+
connectorId: 'connector-1',
82+
requestId: 'request-1',
83+
rehydrate: true,
84+
billingAttribution: BILLING_ATTRIBUTION,
85+
})
86+
87+
await executeConnectorSyncJob({
88+
connectorId: 'connector-1',
89+
requestId: 'request-1',
90+
rehydrate: true,
91+
billingAttribution: BILLING_ATTRIBUTION,
92+
})
93+
94+
expect(mockExecuteSync).toHaveBeenCalledWith('connector-1', {
95+
billingAttribution: BILLING_ATTRIBUTION,
96+
fullSync: undefined,
97+
rehydrate: true,
7598
})
7699
})
77100
})

apps/sim/background/knowledge-connector-sync.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
99
const logger = createLogger('TriggerKnowledgeConnectorSync')
1010

1111
export async function executeConnectorSyncJob(payload: unknown) {
12-
const { connectorId, fullSync, requestId, billingAttribution } =
12+
const { connectorId, fullSync, rehydrate, requestId, billingAttribution } =
1313
assertConnectorSyncPayload(payload)
1414

1515
logger.info(`[${requestId}] Starting connector sync: ${connectorId}`)
1616

1717
try {
18-
const result = await executeSync(connectorId, { billingAttribution, fullSync })
18+
const result = await executeSync(connectorId, { billingAttribution, fullSync, rehydrate })
1919

2020
logger.info(`[${requestId}] Connector sync completed`, {
2121
connectorId,

apps/sim/connectors/confluence/confluence.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ async function fetchLabelsForPages(
9191
return labelsByPageId
9292
}
9393

94+
/**
95+
* Body representation marker embedded in the contentHash. Bumping this
96+
* invalidates every previously-synced Confluence document so a one-time
97+
* re-hydration picks up content newly reachable by the current extraction
98+
* (e.g. the switch from `storage` to rendered `view`, which expands Include
99+
* Page / Excerpt macros). Without it, already-indexed pages whose version is
100+
* unchanged classify as `unchanged` and keep their stale (empty) content.
101+
*/
102+
const CONTENT_REPRESENTATION = 'view'
103+
94104
/**
95105
* Produces a canonical metadata stub with a deterministic contentHash that
96106
* does not depend on which API surface (v1 CQL or v2) returned the page.
@@ -115,7 +125,7 @@ function pageToStub(
115125
contentDeferred: true,
116126
mimeType: 'text/plain',
117127
sourceUrl: options.sourceUrl,
118-
contentHash: `confluence:${page.id}:${versionKey}`,
128+
contentHash: `confluence:${CONTENT_REPRESENTATION}:${page.id}:${versionKey}`,
119129
metadata: {
120130
spaceId: options.spaceId,
121131
status: page.status,
@@ -233,10 +243,18 @@ export const confluenceConnector: ConnectorConfig = {
233243
if (syncContext) syncContext.cloudId = cloudId
234244
}
235245

236-
// Try pages first, fall back to blogposts if not found
246+
/**
247+
* Fetch the `view` representation rather than `storage`. Storage format only
248+
* carries unexpanded macro references (e.g. Include Page / Excerpt Include),
249+
* so "mirrored" content that pulls in another page's body is stripped to
250+
* nothing by `htmlToPlainText`. The `view` representation is server-rendered
251+
* HTML with those macros expanded inline, so included content is indexed too.
252+
* The v2 single-item GET (`/pages/{id}`, `/blogposts/{id}`) supports
253+
* `body-format=view`; only the bulk list endpoints are limited to storage/adf.
254+
*/
237255
let page: Record<string, unknown> | null = null
238256
for (const endpoint of ['pages', 'blogposts']) {
239-
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/${endpoint}/${externalId}?body-format=storage`
257+
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/${endpoint}/${externalId}?body-format=view`
240258
const response = await fetchWithRetry(url, {
241259
method: 'GET',
242260
headers: {
@@ -256,8 +274,8 @@ export const confluenceConnector: ConnectorConfig = {
256274

257275
if (!page) return null
258276
const body = page.body as Record<string, unknown> | undefined
259-
const storage = body?.storage as Record<string, unknown> | undefined
260-
const rawContent = (storage?.value as string) || ''
277+
const view = body?.view as Record<string, unknown> | undefined
278+
const rawContent = (view?.value as string) || ''
261279
const plainText = htmlToPlainText(rawContent)
262280

263281
const labelMap = await fetchLabelsForPages(cloudId, accessToken, [String(page.id)])

apps/sim/connectors/confluence/meta.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ export const confluenceConnectorMeta: ConnectorMeta = {
2222
],
2323
},
2424

25+
/**
26+
* Confluence pages can transclude other pages (Include Page / Excerpt macros).
27+
* Editing an included page changes a container page's rendered `view` without
28+
* bumping the container's version, so its version-based hash can't detect the
29+
* change. A full resync re-hydrates and re-indexes to pick up that drift. This
30+
* lives on the meta so the client can offer "Full resync" only where it applies.
31+
*/
32+
rehydrateOnFullSync: true,
33+
2534
configFields: [
2635
{
2736
id: 'domain',

apps/sim/connectors/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ export interface ConnectorMeta {
127127
*/
128128
supportsIncrementalSync?: boolean
129129

130+
/**
131+
* Whether this connector's extracted content can change without the source item's
132+
* own change-detection hash changing — e.g. a Confluence page that transcludes
133+
* another page via the Include Page / Excerpt macro: editing the included page
134+
* changes the container's rendered `view` output without bumping the container's
135+
* version, so its version-based `contentHash` stays identical.
136+
*
137+
* Incremental syncs remain hash-gated (cheap). On an explicit **full resync**
138+
* (`fullSync`), the engine re-hydrates and re-indexes these connectors' documents
139+
* even when their hash is unchanged, so transcluded/rendered-dependency changes are
140+
* picked up. Only the deliberate full resync pays this re-index cost.
141+
*/
142+
rehydrateOnFullSync?: boolean
143+
130144
/**
131145
* Tag definitions this connector populates. Shown in the add-connector modal
132146
* as opt-out checkboxes. On connector creation, tag definitions are auto-created

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,18 @@ export function useDeleteConnector() {
204204
interface TriggerSyncParams {
205205
knowledgeBaseId: string
206206
connectorId: string
207+
/** Force re-hydration + re-index of rendered content (the "Full resync" action). */
208+
rehydrate?: boolean
207209
}
208210

209-
async function triggerSync({ knowledgeBaseId, connectorId }: TriggerSyncParams): Promise<void> {
211+
async function triggerSync({
212+
knowledgeBaseId,
213+
connectorId,
214+
rehydrate,
215+
}: TriggerSyncParams): Promise<void> {
210216
await requestJson(triggerKnowledgeConnectorSyncContract, {
211217
params: { id: knowledgeBaseId, connectorId },
218+
query: rehydrate ? { rehydrate: true } : {},
212219
})
213220
}
214221

0 commit comments

Comments
 (0)