Skip to content

Commit b930ead

Browse files
committed
fix(knowledge): apply the audit swarm's findings
- Mutation responses failed contract validation because a viewer's membership was required; a mutation now answers with null - Bulk enable/disable wrote to documents outside the caller's scope - The staleness sweep tombstoned deferred and backing-off connectors; it now only touches connectors that completed a run in the window - Members pace on the plain interval so the connector's jittered run finds them due; a member who alone exhausts the run budget backs off; an account that connects mid-run keeps the connector re-dispatching - Gmail and Google Calendar kept a 500-item cap once the cap field was cleared; caps are now written as 0, which every connector reads as unlimited, on the update path as well - Jira, ClickUp, Asana, Linear, and Dropbox classify an unreachable scope on the source's own error rather than a bare 404; an unmatched Atlassian domain is a configuration error, not this person's access - A Credential Group or option serving a members-mode connector cannot be removed under it; the OAuth completion dispatch cannot fail the callback; the content queue refuses a connector that syncs per member - The iterative vector scan runs only for a personal token set; the ACL check validates each element; the flag is read before the enrollment join; connector info is loaded only where it is shown - Search tab: the empty state counts member rows, rows stack, one membership vocabulary shared with the knowledge base banner; an admin can rebind a per-member connector to any matching group
1 parent 1610baf commit b930ead

53 files changed

Lines changed: 881 additions & 546 deletions

Some content is hidden

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

apps/sim/app/api/knowledge/search/utils.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -510,12 +510,12 @@ describe('Knowledge Search Utils', () => {
510510

511511
it('runs both legs and fuses them in hybrid mode', async () => {
512512
/**
513-
* Chains dequeue in creation order. The vector leg opens its transaction
514-
* and applies the scan settings before selecting, so the keyword leg's
515-
* ranking pass is built first, then the vector select, then hydration.
513+
* Chains dequeue in creation order. A workspace-scoped vector leg selects
514+
* directly (the iterative scan is reserved for a personal token set), so
515+
* its select is built first, then the keyword ranking pass, then hydration.
516516
*/
517-
queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }])
518517
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
518+
queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }])
519519
queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')])
520520

521521
const results = await executeKnowledgeSearch({
@@ -532,9 +532,9 @@ describe('Knowledge Search Utils', () => {
532532
})
533533

534534
it('falls back to vector results when the keyword leg fails', async () => {
535-
/** The failing ranking chain is still built first and takes the first queued set. */
536-
queueTableRows(schemaMock.embedding, [{ id: 'never-ranked', keywordRank: 0 }])
537535
queueTableRows(schemaMock.embedding, [makeResult('vector-hit')])
536+
/** The failing ranking chain is still built and takes the second queued set. */
537+
queueTableRows(schemaMock.embedding, [{ id: 'never-ranked', keywordRank: 0 }])
538538

539539
/**
540540
* Both legs share one `orderBy` spy, so target the keyword leg by its

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,16 @@ import {
3636
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field'
3737
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
3838
import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
39-
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
39+
import {
40+
BROWSE_WITH_HINT,
41+
SYNC_INTERVALS,
42+
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
4043
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
4144
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
42-
import { useConnectorMemberGroupOptions } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
45+
import {
46+
memberCapFieldIds,
47+
useConnectorMemberGroupOptions,
48+
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
4349
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
4450
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
4551
import { getBlock } from '@/blocks'
@@ -104,11 +110,9 @@ export function AddConnectorModal({
104110
/** Several groups collect this provider's accounts: the admin has to say which. */
105111
const membersChoiceOpen =
106112
isMembersMode && groupOptions.needsChoice && !access.credentialGroupOptionId
107-
/** Fields a per-member crawl refuses: a cap would hide part of a member's corpus. */
108-
const memberCapFieldIds = useMemo(
109-
() =>
110-
new Set(isMembersMode ? (connectorConfig?.permissionScopedListing?.capFieldIds ?? []) : []),
111-
[isMembersMode, connectorConfig]
113+
const hiddenCapFieldIds = useMemo(
114+
() => memberCapFieldIds(connectorConfig, access.accessMode),
115+
[connectorConfig, access.accessMode]
112116
)
113117
/** True when the connector declares its key optional (public sources need none). */
114118
const isApiKeyOptional =
@@ -202,7 +206,7 @@ export function AddConnectorModal({
202206
for (const field of connectorConfig.configFields) {
203207
if (!field.required) continue
204208
if (!isFieldVisible(field)) continue
205-
if (memberCapFieldIds.has(field.id)) continue
209+
if (hiddenCapFieldIds.has(field.id)) continue
206210
if (!isFieldPopulated(field)) return false
207211
}
208212
return true
@@ -211,7 +215,7 @@ export function AddConnectorModal({
211215
isApiKeyMode,
212216
isMembersMode,
213217
membersChoiceOpen,
214-
memberCapFieldIds,
218+
hiddenCapFieldIds,
215219
isApiKeyOptional,
216220
apiKeyValue,
217221
effectiveCredentialId,
@@ -226,7 +230,7 @@ export function AddConnectorModal({
226230

227231
const resolvedConfig: Record<string, unknown> = {}
228232
for (const [key, value] of Object.entries(resolveSourceConfig())) {
229-
if (memberCapFieldIds.has(key)) continue
233+
if (hiddenCapFieldIds.has(key)) continue
230234
if (Array.isArray(value)) {
231235
if (value.length > 0) resolvedConfig[key] = value
232236
} else if (typeof value === 'string') {
@@ -345,7 +349,6 @@ export function AddConnectorModal({
345349
<>
346350
{!isApiKeyMode && memberAccessAvailable && (
347351
<ConnectorAccessField
348-
workspaceId={workspaceId}
349352
connectorConfig={connectorConfig}
350353
value={access}
351354
onChange={setAccess}
@@ -382,11 +385,7 @@ export function AddConnectorModal({
382385
<ChipModalField
383386
type='custom'
384387
title={isMembersMode ? 'Browse with' : 'Account'}
385-
hint={
386-
isMembersMode
387-
? `Only used to pick folders and spaces below. The connector syncs as each member, not as this account.`
388-
: undefined
389-
}
388+
hint={isMembersMode ? BROWSE_WITH_HINT : undefined}
390389
>
391390
<ChipCombobox
392391
options={[
@@ -425,7 +424,7 @@ export function AddConnectorModal({
425424
canonicalGroups={canonicalGroups}
426425
canonicalModes={canonicalModes}
427426
isFieldVisible={(field) =>
428-
isFieldVisible(field) && !memberCapFieldIds.has(field.id)
427+
isFieldVisible(field) && !hiddenCapFieldIds.has(field.id)
429428
}
430429
onFieldChange={handleFieldChange}
431430
onToggleCanonicalMode={toggleCanonicalMode}
@@ -502,7 +501,13 @@ export function AddConnectorModal({
502501
<ChipModalFooter
503502
onCancel={() => onOpenChange(false)}
504503
primaryAction={{
505-
label: isCreating ? 'Connecting…' : 'Connect & Sync',
504+
label: isCreating
505+
? isMembersMode
506+
? 'Creating…'
507+
: 'Connecting…'
508+
: isMembersMode
509+
? 'Create & Invite'
510+
: 'Connect & Sync',
506511
onClick: handleSubmit,
507512
disabled: !canSubmit || isCreating,
508513
}}

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx

Lines changed: 24 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@
22

33
import type { ReactNode } from 'react'
44
import { ButtonGroup, ButtonGroupItem, ChipCombobox, ChipModalField } from '@sim/emcn'
5-
import Link from 'next/link'
65
import {
76
type ConnectorMemberGroupOptions,
87
decodeConnectorMemberGroupOption,
98
encodeConnectorMemberGroupOption,
109
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options'
11-
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
1210
import type { ConnectorMeta } from '@/connectors/types'
1311

1412
/** What the caller chose; `members` may name the option the connector crawls with. */
@@ -19,7 +17,6 @@ export interface ConnectorAccessSelection {
1917
}
2018

2119
interface ConnectorAccessFieldProps {
22-
workspaceId: string
2320
connectorConfig: ConnectorMeta
2421
value: ConnectorAccessSelection
2522
onChange: (value: ConnectorAccessSelection) => void
@@ -30,6 +27,11 @@ interface ConnectorAccessFieldProps {
3027
disabled?: boolean
3128
/** Whether per-member access may be chosen; false leaves only the way back to workspace access. */
3229
allowMembers?: boolean
30+
/**
31+
* Whether the connector already syncs per member, so any matching group may
32+
* be chosen, not only when several make the choice necessary.
33+
*/
34+
canRebind?: boolean
3335
/** Rendered under the selection, for a caller that applies the change with its own control. */
3436
footer?: ReactNode
3537
}
@@ -43,19 +45,16 @@ interface ConnectorAccessFieldProps {
4345
* several matching groups is asked which one to use.
4446
*/
4547
export function ConnectorAccessField({
46-
workspaceId,
4748
connectorConfig,
4849
value,
4950
onChange,
5051
groupOptions,
5152
canAdmin,
5253
disabled = false,
5354
allowMembers = true,
55+
canRebind = false,
5456
footer,
5557
}: ConnectorAccessFieldProps) {
56-
const { features } = useWorkspaceHostContext()
57-
const credentialGroupsAvailable = features?.credentialGroups === true
58-
5958
if (!groupOptions.supported) return null
6059

6160
if (!canAdmin) {
@@ -79,11 +78,7 @@ export function ConnectorAccessField({
7978
? encodeConnectorMemberGroupOption(value.credentialGroupId, value.credentialGroupOptionId)
8079
: undefined
8180
const { options, needsChoice, isLoading, error } = groupOptions
82-
const membersHint = !credentialGroupsAvailable
83-
? 'Per-member access needs Credential Groups, which are not available on this plan.'
84-
: !allowMembers
85-
? 'Per-member access is turned off for this workspace.'
86-
: undefined
81+
const showPicker = needsChoice || (canRebind && options.length > 0)
8782

8883
return (
8984
<ChipModalField
@@ -92,8 +87,10 @@ export function ConnectorAccessField({
9287
error={error?.message}
9388
hint={
9489
value.accessMode === 'members'
95-
? 'Each member sees only the documents their own account can open. Scheduled, API, and chat runs see workspace-visible documents only.'
96-
: membersHint
90+
? `Everyone in the workspace is invited by email to connect their ${connectorConfig.name} account when the first sync starts. Each member sees only the documents their own account can open; scheduled, API, and chat runs see workspace-visible documents only.`
91+
: allowMembers
92+
? undefined
93+
: 'Per-member access is turned off for this workspace.'
9794
}
9895
>
9996
<div className='flex flex-col gap-2'>
@@ -106,47 +103,23 @@ export function ConnectorAccessField({
106103
<ButtonGroupItem value='workspace' disabled={disabled}>
107104
Workspace
108105
</ButtonGroupItem>
109-
<ButtonGroupItem
110-
value='members'
111-
disabled={disabled || !allowMembers || !credentialGroupsAvailable}
112-
>
106+
<ButtonGroupItem value='members' disabled={disabled || !allowMembers}>
113107
Per member
114108
</ButtonGroupItem>
115109
</ButtonGroup>
116110

117-
{value.accessMode === 'members' && (
118-
<>
119-
{needsChoice && (
120-
<ChipCombobox
121-
options={options}
122-
value={selectedValue}
123-
onChange={(next) => {
124-
const decoded = decodeConnectorMemberGroupOption(next)
125-
if (decoded) onChange({ accessMode: 'members', ...decoded })
126-
}}
127-
placeholder='Choose which credential group members connect through'
128-
isLoading={isLoading}
129-
disabled={disabled || Boolean(error)}
130-
/>
131-
)}
132-
<p className='text-[var(--text-muted)] text-caption leading-snug'>
133-
{options.length === 1
134-
? `Members connect through ${options[0].label}. `
135-
: options.length === 0
136-
? `A credential group named ${connectorConfig.name} is created. `
137-
: ''}
138-
Everyone in the workspace is invited by email to connect their own{' '}
139-
{connectorConfig.name} account as the first sync starts, and people who join later are
140-
invited automatically. Manage members in{' '}
141-
<Link
142-
href={`/workspace/${workspaceId}/settings/credential-groups`}
143-
className='text-[var(--text-primary)] underline underline-offset-2'
144-
>
145-
Settings
146-
</Link>
147-
.
148-
</p>
149-
</>
111+
{value.accessMode === 'members' && showPicker && (
112+
<ChipCombobox
113+
options={options}
114+
value={selectedValue}
115+
onChange={(next) => {
116+
const decoded = decodeConnectorMemberGroupOption(next)
117+
if (decoded) onChange({ accessMode: 'members', ...decoded })
118+
}}
119+
placeholder='Choose which credential group members connect through'
120+
isLoading={isLoading}
121+
disabled={disabled || Boolean(error)}
122+
/>
150123
)}
151124

152125
{footer}

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

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ const SYNC_IN_FLIGHT_TOOLTIP = {
8888
} as const
8989

9090
/** The member engine's own in-flight states, shown when the connector syncs per member. */
91-
const MEMBER_SYNC_IN_FLIGHT_TOOLTIP = {
91+
const MEMBER_SYNC_IN_FLIGHT_TOOLTIP: Partial<Record<MemberSyncStatus, string>> = {
9292
pending: 'Member sync queued',
9393
running: 'Syncing members',
94-
} as const
94+
}
9595

9696
/** How each member-engine status reads on the card's badge. */
9797
const MEMBER_SYNC_STATUS_AS_CONNECTOR_STATUS = {
@@ -383,11 +383,7 @@ function ConnectorCard({
383383
syncInFlight || connector.status === 'disabled' || isPaused || memberSyncDisabled
384384
const syncTooltip =
385385
SYNC_IN_FLIGHT_TOOLTIP[connector.status as keyof typeof SYNC_IN_FLIGHT_TOOLTIP] ??
386-
(syncsPerMember
387-
? MEMBER_SYNC_IN_FLIGHT_TOOLTIP[
388-
connector.memberSyncStatus as keyof typeof MEMBER_SYNC_IN_FLIGHT_TOOLTIP
389-
]
390-
: undefined) ??
386+
(syncsPerMember ? MEMBER_SYNC_IN_FLIGHT_TOOLTIP[connector.memberSyncStatus] : undefined) ??
391387
(isPaused
392388
? 'Resume to sync'
393389
: memberSyncDisabled
@@ -442,18 +438,9 @@ function ConnectorCard({
442438
{statusConfig.label}
443439
</Badge>
444440
{syncsPerMember && (
445-
<Tooltip.Root>
446-
<Tooltip.Trigger asChild>
447-
<Badge variant='gray' size='sm' className='flex-shrink-0'>
448-
<Users className='size-3' />
449-
Per member
450-
</Badge>
451-
</Tooltip.Trigger>
452-
<Tooltip.Content>
453-
Synced once per enrolled member; each person sees only the documents their own
454-
account can open.
455-
</Tooltip.Content>
456-
</Tooltip.Root>
441+
<Badge variant='gray' size='sm' icon={Users} className='flex-shrink-0'>
442+
Per member
443+
</Badge>
457444
)}
458445
</div>
459446
<div className='flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[var(--text-muted)] text-xs'>
@@ -946,7 +933,7 @@ interface MemberSyncHistoryProps {
946933
* membership stands. A run that ended with members still due re-dispatches
947934
* itself, so several short rows in a row are one drain, not a fault.
948935
*/
949-
export function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps) {
936+
function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistoryProps) {
950937
if (isLoading) {
951938
return (
952939
<div className='flex items-center gap-2 rounded-md bg-[var(--surface-3)] px-2 py-2 text-[var(--text-muted)] text-xs'>
@@ -1002,7 +989,10 @@ export function MemberSyncHistory({ logs, members, isLoading }: MemberSyncHistor
1002989
? ''
1003990
: 's'}
1004991
{log.membersFailed > 0 && (
1005-
<span className='text-[var(--text-error)]'> !{log.membersFailed}</span>
992+
<span className='text-[var(--text-error)]'>
993+
{' '}
994+
· {log.membersFailed} failed
995+
</span>
1006996
)}
1007997
{changes > 0 ? (
1008998
<>

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/consts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/** Under the account picker of a per-member connector, whose account only browses. */
2+
export const BROWSE_WITH_HINT =
3+
'Only used to pick folders and spaces below. The connector syncs as each member, not as this account.'
4+
15
export const SYNC_INTERVALS = [
26
{ label: 'Live', value: 5, requiresMax: true },
37
{ label: 'Every hour', value: 60, requiresMax: false },

0 commit comments

Comments
 (0)