Skip to content

Commit 06aebf9

Browse files
committed
fix(knowledge): skip a required-key connector without a key and write the unscheduled state only over the row the run observed
1 parent f289f68 commit 06aebf9

4 files changed

Lines changed: 64 additions & 7 deletions

File tree

‎apps/sim/connectors/auth.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,31 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { isConnectorCredentialTypeAllowed } from '@/connectors/auth'
5+
import { connectorHasAuthSource, isConnectorCredentialTypeAllowed } from '@/connectors/auth'
66
import { confluenceConnectorMeta } from '@/connectors/confluence/meta'
77
import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta'
88
import { slackConnectorMeta } from '@/connectors/slack/meta'
99

10+
describe('connectorHasAuthSource', () => {
11+
const none = { credentialId: null, encryptedApiKey: null }
12+
const keyed = { credentialId: null, encryptedApiKey: 'enc' }
13+
const linked = { credentialId: 'cred', encryptedApiKey: null }
14+
15+
it('mirrors the token resolver for every auth shape', () => {
16+
expect(connectorHasAuthSource({ mode: 'apiKey', label: 'Key' }, none)).toBe(false)
17+
expect(connectorHasAuthSource({ mode: 'apiKey', label: 'Key' }, keyed)).toBe(true)
18+
expect(connectorHasAuthSource({ mode: 'apiKey', label: 'Key', optional: true }, none)).toBe(
19+
true
20+
)
21+
expect(connectorHasAuthSource({ mode: 'oauth', provider: 'slack' }, none)).toBe(false)
22+
expect(connectorHasAuthSource({ mode: 'oauth', provider: 'slack' }, linked)).toBe(true)
23+
expect(connectorHasAuthSource({ mode: 'oauth', provider: 'slack' }, keyed)).toBe(false)
24+
expect(
25+
connectorHasAuthSource({ mode: 'oauth', provider: 'github', apiKey: { label: 'PAT' } }, keyed)
26+
).toBe(true)
27+
})
28+
})
29+
1030
describe('connector credential eligibility', () => {
1131
it.each([confluenceConnectorMeta, googleDriveConnectorMeta])(
1232
'requires a service account for $name central indexing and preserves member and workspace OAuth',

‎apps/sim/connectors/auth.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,9 @@ export function connectorHasAuthSource(
3434
auth: ConnectorAuthConfig,
3535
connector: { credentialId: string | null; encryptedApiKey: string | null }
3636
): boolean {
37-
return (
38-
auth.mode === 'apiKey' ||
39-
Boolean(connector.credentialId) ||
40-
Boolean(getConnectorApiKeyConfig(auth) && connector.encryptedApiKey)
41-
)
37+
const apiKeyConfig = getConnectorApiKeyConfig(auth)
38+
if (apiKeyConfig && connector.encryptedApiKey) return true
39+
return auth.mode === 'apiKey' ? apiKeyConfig?.optional === true : Boolean(connector.credentialId)
4240
}
4341

4442
/** Workspace token input supported by a connector, independent of its member OAuth method. */

‎apps/sim/lib/knowledge/connectors/sync-engine.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ vi.mock('@/connectors/registry.server', () => ({
109109
getDocument: mockGetDocument,
110110
listDocuments: mockListDocuments,
111111
},
112+
keyed: {
113+
name: 'Keyed',
114+
auth: { mode: 'apiKey' },
115+
getDocument: mockGetDocument,
116+
listDocuments: mockListDocuments,
117+
},
112118
oauth: {
113119
name: 'OAuth',
114120
auth: { mode: 'oauth', provider: 'example' },
@@ -3367,6 +3373,23 @@ describe('executeSync hard-delete reconciliation', () => {
33673373
)
33683374
})
33693375

3376+
it('leaves an API-key connector whose required key is missing unscheduled', async () => {
3377+
const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine')
3378+
3379+
queueTableRows(schemaMock.knowledgeConnector, [
3380+
{ ...CONNECTOR, connectorType: 'keyed', credentialId: null, encryptedApiKey: null },
3381+
])
3382+
3383+
const result = await executeSync('c-1', {
3384+
billingAttribution: { workspaceId: 'ws-1' } as never,
3385+
})
3386+
3387+
expect(result.skipReason).toBe('credential_missing')
3388+
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
3389+
expect.objectContaining({ consecutiveFailures: expect.any(Number) })
3390+
)
3391+
})
3392+
33703393
it('releases the lock when it errors a connector whose knowledge base is gone', async () => {
33713394
const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine')
33723395

‎apps/sim/lib/knowledge/connectors/sync-engine.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -856,10 +856,26 @@ export async function executeSync(
856856
*/
857857
if (!connectorHasAuthSource(connectorConfig.auth, connectorBeforeLock)) {
858858
logger.warn('Skipping sync: connector has no credential to authenticate with', { connectorId })
859+
/**
860+
* Written only while the row is still the credential-less runnable row this run read: a
861+
* reconnect that landed in between keeps its schedule, and a paused or disabled connector
862+
* a stale task reached keeps its status.
863+
*/
864+
const observed = (
865+
column: typeof knowledgeConnector.credentialId | typeof knowledgeConnector.encryptedApiKey,
866+
value: string | null
867+
) => (value === null ? isNull(column) : eq(column, value))
859868
await db
860869
.update(knowledgeConnector)
861870
.set(buildSyncUnscheduledUpdate(new Date(), CREDENTIAL_REMOVED_SYNC_ERROR))
862-
.where(eq(knowledgeConnector.id, connectorId))
871+
.where(
872+
and(
873+
eq(knowledgeConnector.id, connectorId),
874+
observed(knowledgeConnector.credentialId, connectorBeforeLock.credentialId),
875+
observed(knowledgeConnector.encryptedApiKey, connectorBeforeLock.encryptedApiKey),
876+
inArray(knowledgeConnector.status, [...RUNNABLE_CONNECTOR_STATUSES])
877+
)
878+
)
863879
return { ...result, skipReason: 'credential_missing' }
864880
}
865881

0 commit comments

Comments
 (0)