Skip to content

Commit f890e5f

Browse files
committed
feat(search): add feature-flagged live provider retrieval
1 parent 9952888 commit f890e5f

42 files changed

Lines changed: 3375 additions & 14 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/actions/docker-build/action.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ inputs:
1919
tags:
2020
description: Comma-separated list of tags to push.
2121
required: true
22+
build-args:
23+
description: Newline-separated Docker build arguments.
24+
required: false
25+
default: ''
2226
max-cache-size-mb:
2327
description: >-
2428
Layer cache to retain after this action prunes, in MB. Must stay above one
@@ -72,6 +76,7 @@ runs:
7276
platforms: ${{ inputs.platforms }}
7377
push: true
7478
tags: ${{ inputs.tags }}
79+
build-args: ${{ inputs.build-args }}
7580
provenance: false
7681
sbom: false
7782

@@ -177,5 +182,6 @@ runs:
177182
platforms: ${{ inputs.platforms }}
178183
push: true
179184
tags: ${{ inputs.tags }}
185+
build-args: ${{ inputs.build-args }}
180186
provenance: false
181187
sbom: false

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ jobs:
221221
file: ${{ matrix.dockerfile }}
222222
platforms: linux/amd64
223223
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}-dev
224+
build-args: SIM_SEARCH_LIVE_DEFAULT=true
224225
max-cache-size-mb: ${{ matrix.cache_mb }}
225226

226227
# Build and upload tasks alongside tests and images. The unpromoted version

‎apps/sim/app/api/knowledge/search/route.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import {
44
internalRateLimits,
55
internalSessionAuth,
66
} from '@/lib/api/server/routes'
7+
import { isLiveEnterpriseSearchEnabled } from '@/lib/core/config/env-flags'
78
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
89
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
910
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
1011
import { DEFAULT_RERANKER_MODEL } from '@/lib/knowledge/reranker-models'
1112
import { sourceAuthor } from '@/lib/knowledge/search/author'
13+
import { searchLiveKnowledge } from '@/lib/sim-search/live/application'
1214

1315
const DIRECT_SEARCH_VECTOR_BUDGET_MS = 3000
1416

15-
export const POST = defineInternalJsonRoute({
17+
const indexedSearchRoute = defineInternalJsonRoute({
1618
contract: searchWorkspaceKnowledgeContract,
1719
auth: internalSessionAuth,
1820
operation: knowledgeOperations.search,
@@ -64,3 +66,19 @@ export const POST = defineInternalJsonRoute({
6466
}
6567
},
6668
})
69+
70+
const liveSearchRoute = defineInternalJsonRoute({
71+
contract: searchWorkspaceKnowledgeContract,
72+
auth: internalSessionAuth,
73+
operation: knowledgeOperations.search,
74+
rateLimit: internalRateLimits.none({
75+
reason:
76+
'Provider limits apply independently to each user grant; requests have bounded fanout and deadlines',
77+
}),
78+
errorPolicy: internalKnowledgeErrorPolicies.search,
79+
mapInput: ({ body }, { request }) => ({ ...body, signal: request.signal }),
80+
useCase: searchLiveKnowledge,
81+
present: (data) => ({ success: true as const, data }),
82+
})
83+
84+
export const POST = isLiveEnterpriseSearchEnabled ? liveSearchRoute : indexedSearchRoute

‎apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/
99
import type { SearchConnector } from '@/lib/sim-search/connectors'
1010

1111
const mocks = vi.hoisted(() => ({
12+
live: false,
1213
context: vi.fn(),
1314
sources: vi.fn(),
1415
overview: vi.fn(),
@@ -30,6 +31,9 @@ const mocks = vi.hoisted(() => ({
3031
reconnectOrganizationAccount: vi.fn(),
3132
refetchAccounts: vi.fn(),
3233
}))
34+
vi.mock('@/lib/core/config/deployment-shape', () => ({
35+
useDeploymentShape: () => ({ features: { liveEnterpriseSearch: mocks.live } }),
36+
}))
3337
vi.mock('@/hooks/queries/organization-accounts', () => ({
3438
organizationAccountsKeys: { detail: (id: string) => ['organization-accounts', 'detail', id] },
3539
useOrganizationAccounts: mocks.organizationAccounts,
@@ -173,6 +177,7 @@ let rows: SearchSourceSummary[]
173177
let queryOverrides: Record<string, unknown>
174178
beforeEach(() => {
175179
vi.clearAllMocks()
180+
mocks.live = false
176181
vi.spyOn(toast, 'error').mockReturnValue('toast')
177182
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
178183
mocks.setupConnector = null
@@ -968,3 +973,56 @@ describe('grouped member integrations', () => {
968973
expect(document.body.textContent).not.toContain('Choose the matching account')
969974
})
970975
})
976+
977+
describe('live integrations backend selection', () => {
978+
it('connects through existing OAuth enrollment without loading indexed sources', async () => {
979+
mocks.live = true
980+
mocks.organizationAccounts.mockReturnValue({
981+
data: {
982+
credentialGroup: {
983+
status: 'active',
984+
options: [
985+
{ id: 'drive', provider: 'google-drive', label: 'Google Drive', status: 'active' },
986+
],
987+
},
988+
viewerAccounts: [],
989+
},
990+
isError: false,
991+
})
992+
await render('', <OrganizationIntegrations />)
993+
expect(container.textContent).toContain('no indexing setup is needed')
994+
await act(async () => buttons('Connect')[0].click())
995+
expect(mocks.connectOrganizationAccount).toHaveBeenCalledWith(
996+
{ organizationId: scope.organizationId, optionId: 'drive' },
997+
expect.any(Object)
998+
)
999+
expect(mocks.sources).not.toHaveBeenCalled()
1000+
expect(mocks.overview).not.toHaveBeenCalled()
1001+
expect(mocks.integrations).not.toHaveBeenCalled()
1002+
expect(mocks.connectSearchSource).not.toHaveBeenCalled()
1003+
})
1004+
it('offers reconnect for an existing personal grant', async () => {
1005+
mocks.live = true
1006+
mocks.organizationAccounts.mockReturnValue({
1007+
data: {
1008+
credentialGroup: {
1009+
status: 'active',
1010+
options: [{ id: 'slack', provider: 'slack', label: 'Slack', status: 'active' }],
1011+
},
1012+
viewerAccounts: [
1013+
{
1014+
credentialId: 'my-slack',
1015+
optionId: 'slack',
1016+
displayName: 'My Slack',
1017+
status: 'needs_reauth',
1018+
},
1019+
],
1020+
},
1021+
isError: false,
1022+
})
1023+
await render('', <OrganizationIntegrations />)
1024+
await act(async () => buttons('Reconnect')[0].click())
1025+
expect(mocks.reconnectOrganizationAccount).toHaveBeenCalledWith('my-slack', expect.any(Object))
1026+
expect(container.textContent).toContain('reconnect needed')
1027+
})
1028+
})

‎apps/sim/app/o/[organizationId]/integrations/integrations.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use client'
22

3+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
34
import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target'
45
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
56
import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page'
67
import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
8+
import { LiveMemberIntegrations } from '@/app/o/[organizationId]/integrations/live-member-integrations'
79
import { MemberIntegrationsList } from '@/app/o/[organizationId]/integrations/member-integrations-list'
810
import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions'
911
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
@@ -23,6 +25,7 @@ export function OrganizationIntegrations({
2325
useOAuthReturnRouter()
2426
useDesktopOAuthConnectListener()
2527
const { organization } = useOrganizationContext()
28+
const { features } = useDeploymentShape()
2629
const { search } = useOrganizationPageFilters()
2730
const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS)
2831

@@ -38,14 +41,18 @@ export function OrganizationIntegrations({
3841
)
3942
}
4043
>
41-
{connectionRequest && (
44+
{!features.liveEnterpriseSearch && connectionRequest && (
4245
<SearchIntegrationConnection
4346
organizationId={organization.id}
4447
{...connectionRequest}
4548
controlId='integrations-link'
4649
/>
4750
)}
48-
<MemberIntegrationsList search={sourceSearch} />
51+
{features.liveEnterpriseSearch ? (
52+
<LiveMemberIntegrations organizationId={organization.id} search={sourceSearch} />
53+
) : (
54+
<MemberIntegrationsList search={sourceSearch} />
55+
)}
4956
</OrganizationPage>
5057
)
5158
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
'use client'
2+
3+
import { Chip, ChipLink, toast } from '@sim/emcn'
4+
import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/organization-accounts'
5+
import { connectorDisplayName } from '@/lib/sim-search/connectors'
6+
import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu'
7+
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
8+
import {
9+
useConnectOrganizationAccount,
10+
useOrganizationAccounts,
11+
useReconnectPersonalOrganizationAccount,
12+
} from '@/hooks/queries/organization-accounts'
13+
14+
const SOURCES: Record<string, string> = {
15+
'google-drive': 'google_drive',
16+
gmail: 'gmail',
17+
'google-email': 'gmail',
18+
'google-calendar': 'google_calendar',
19+
'google-docs': 'google_drive',
20+
'google-sheets': 'google_drive',
21+
'google-slides': 'google_drive',
22+
'github-repositories': 'github',
23+
slack: 'slack',
24+
jira: 'jira',
25+
confluence: 'confluence',
26+
}
27+
28+
/** Uses the member OAuth enrollment flow directly, without creating or configuring an index. */
29+
export function LiveMemberIntegrations({
30+
organizationId,
31+
search,
32+
}: {
33+
organizationId: string
34+
search: string
35+
}) {
36+
const inventory = useOrganizationAccounts(organizationId)
37+
const connect = useConnectOrganizationAccount()
38+
const reconnect = useReconnectPersonalOrganizationAccount()
39+
const navigate = (result: OrganizationAccountConnectionResponse) =>
40+
window.location.assign(result.authorizationUrl ?? result.invitationLink)
41+
const onError = (error: Error) => toast.error(error.message)
42+
if (inventory.isError)
43+
return (
44+
<div className='p-4'>
45+
<p>Could not load connected accounts.</p>
46+
<Chip onClick={() => void inventory.refetch()}>Retry</Chip>
47+
</div>
48+
)
49+
if (!inventory.data) return <p className='p-4'>Loading connected accounts…</p>
50+
const data = inventory.data
51+
const options =
52+
data.credentialGroup?.status === 'active'
53+
? data.credentialGroup.options.filter(
54+
(option) => option.status === 'active' && SOURCES[option.provider]
55+
)
56+
: []
57+
const visible = options.filter((option) =>
58+
`${option.label} ${option.provider}`.toLowerCase().includes(search.toLowerCase())
59+
)
60+
return (
61+
<div className='flex flex-col gap-3'>
62+
<p className='px-4 py-2 text-[var(--text-muted)] text-small'>
63+
Search runs directly through your accounts. Connect or reconnect here to grant permission;
64+
no indexing setup is needed.
65+
</p>
66+
{visible.map((option) => {
67+
const accounts = (data.viewerAccounts ?? []).filter(
68+
(account) => account.optionId === option.id
69+
)
70+
const name = connectorDisplayName(SOURCES[option.provider])
71+
return (
72+
<SettingsResourceRow
73+
key={option.id}
74+
title={option.label || name}
75+
description={
76+
accounts.length
77+
? accounts
78+
.map(
79+
(account) =>
80+
`${account.displayName}${account.status === 'needs_reauth' ? ' (reconnect needed)' : ''}`
81+
)
82+
.join(', ')
83+
: `Connect your ${name} account to search as you.`
84+
}
85+
trailing={
86+
<div className='flex items-center gap-2'>
87+
<DisconnectAccountMenu
88+
organizationId={organizationId}
89+
integrationName={name}
90+
accounts={accounts}
91+
/>
92+
{accounts.map((account) => (
93+
<Chip
94+
key={account.credentialId}
95+
disabled={reconnect.isPending}
96+
onClick={() =>
97+
reconnect.mutate(account.credentialId, { onSuccess: navigate, onError })
98+
}
99+
>
100+
Reconnect {accounts.length > 1 ? account.displayName : ''}
101+
</Chip>
102+
))}
103+
<Chip
104+
variant='primary'
105+
disabled={connect.isPending}
106+
onClick={() =>
107+
connect.mutate(
108+
{ organizationId, optionId: option.id },
109+
{ onSuccess: navigate, onError }
110+
)
111+
}
112+
>
113+
{accounts.length ? 'Add account' : 'Connect'}
114+
</Chip>
115+
</div>
116+
}
117+
/>
118+
)
119+
})}
120+
{options.length === 0 && (
121+
<p className='px-4 text-small'>
122+
An organization admin needs to enable account providers in Credential Groups.
123+
</p>
124+
)}
125+
{data.canManage && (
126+
<div className='px-4'>
127+
<ChipLink href={`/o/${organizationId}/settings/connected-accounts`}>
128+
Configure account providers
129+
</ChipLink>
130+
</div>
131+
)}
132+
</div>
133+
)
134+
}

0 commit comments

Comments
 (0)