Skip to content

Commit 49ec9a9

Browse files
authored
fix(loading): debounce selector search, fix two row-cache snapshots, and move Vertex refresh to the app (#6667)
* fix(selectors): debounce the provider search, and keep the list while it refetches * fix(tables): snapshot row pages from the non-collidable prefix * fix(vertex): resolve the OAuth token through the app, which holds the client config * fix(selectors): drop the previous-options fallback so a context change cannot leave stale ones selectable * fix(selectors): clear the search without waiting out the debounce * fix(realtime): wait for the streak-reset preconditions instead of sleeping past them
1 parent 86dbd0a commit 49ec9a9

10 files changed

Lines changed: 181 additions & 23 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type React from 'react'
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import { Button, Combobox as EditableCombobox } from '@sim/emcn'
44
import { X } from '@sim/emcn/icons'
5+
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
56
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
67
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
78
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
@@ -14,6 +15,7 @@ import {
1415
useSelectorOptionMap,
1516
useSelectorOptions,
1617
} from '@/hooks/selectors/use-selector-query'
18+
import { useDebounce } from '@/hooks/use-debounce'
1719

1820
interface SelectorComboboxProps {
1921
blockId: string
@@ -63,14 +65,26 @@ export function SelectorCombobox({
6365
const [searchTerm, setSearchTerm] = useState('')
6466
const [isEditing, setIsEditing] = useState(false)
6567
const [multiInput, setMultiInput] = useState('')
68+
/**
69+
* The search reaches the provider, so it is debounced before it enters the query key
70+
* rather than on every keystroke — several of these selectors are rate-limited by the
71+
* provider. Only the query sees the debounced value; the input stays on `searchTerm`.
72+
*
73+
* Clearing is not debounced: a multi-select pick resets the term so the next choice comes
74+
* from the full list, and waiting out the delay would leave the previous filtered results
75+
* on screen. This mirrors the shared debounced-search setter, which also flushes empty.
76+
*/
77+
const trimmedSearch = searchTerm.trim()
78+
const debouncedSearch = useDebounce(trimmedSearch, SEARCH_DEBOUNCE_MS)
79+
const activeSearch = trimmedSearch === '' ? '' : debouncedSearch
6680
const {
6781
data: options = [],
6882
isLoading,
6983
hasMore,
7084
error,
7185
} = useSelectorOptions(selectorKey, {
7286
context: selectorContext,
73-
search: allowSearch ? searchTerm : undefined,
87+
search: allowSearch ? activeSearch : undefined,
7488
})
7589
const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
7690
context: selectorContext,

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2543,6 +2543,7 @@ export class AgentBlockHandler implements BlockHandler {
25432543
credentialId: providerRequest.vertexCredential,
25442544
actingUserId: ctx.userId,
25452545
workspaceId: ctx.workspaceId,
2546+
workflowId: ctx.workflowId,
25462547
callerLabel: 'vertex-agent',
25472548
})
25482549
}

apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
1515

1616
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
1717

18+
vi.mock('@/executor/utils/credential-token', () => ({
19+
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
20+
}))
21+
1822
vi.mock('@/lib/credentials/access', () => ({
1923
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
2024
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),

apps/sim/executor/handlers/evaluator/evaluator-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
182182
credentialId: evaluatorConfig.vertexCredential,
183183
actingUserId: ctx.userId,
184184
workspaceId: ctx.workspaceId,
185+
workflowId: ctx.workflowId,
185186
callerLabel: 'vertex-evaluator',
186187
})
187188
}

apps/sim/executor/handlers/router/router-handler.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
2121
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
2222
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
2323

24+
vi.mock('@/executor/utils/credential-token', () => ({
25+
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
26+
}))
27+
2428
vi.mock('@/lib/credentials/access', () => ({
2529
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
2630
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),

apps/sim/executor/handlers/router/router-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export class RouterBlockHandler implements BlockHandler {
117117
credentialId: routerConfig.vertexCredential,
118118
actingUserId: ctx.userId,
119119
workspaceId: ctx.workspaceId,
120+
workflowId: ctx.workflowId,
120121
callerLabel: 'vertex-router',
121122
})
122123
}
@@ -279,6 +280,7 @@ export class RouterBlockHandler implements BlockHandler {
279280
credentialId: routerConfig.vertexCredential,
280281
actingUserId: ctx.userId,
281282
workspaceId: ctx.workspaceId,
283+
workflowId: ctx.workflowId,
282284
callerLabel: 'vertex-router',
283285
})
284286
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createLogger } from '@sim/logger'
2+
import { generateInternalToken } from '@/lib/auth/internal'
3+
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
4+
5+
const logger = createLogger('ExecutorCredentialToken')
6+
7+
/**
8+
* Fetches a credential's access token from the app rather than resolving it here.
9+
*
10+
* Refreshing an OAuth token needs the provider's client id and secret, read through
11+
* `requireOAuthClientCapability`, which THROWS when they are absent. Only the app
12+
* container loads those (from `SIM_ENV_SECRET_ID`); workflow execution runs in a
13+
* Trigger.dev worker whose environment does not carry them. Resolving in-process there
14+
* turns every credential whose access token has expired into a refresh failure, and a
15+
* still-valid token hides it until the token lapses.
16+
*
17+
* See `.claude/rules/sim-architecture.md`, "The app/worker runtime boundary".
18+
*
19+
* The route authorizes the credential itself, so this never widens access.
20+
*/
21+
export async function fetchCredentialAccessToken(params: {
22+
requestId: string
23+
credentialId: string
24+
userId: string
25+
workflowId?: string
26+
}): Promise<string> {
27+
const { requestId, credentialId, userId, workflowId } = params
28+
29+
const url = new URL('/api/auth/oauth/token', getInternalApiBaseUrl())
30+
if (workflowId) url.searchParams.set('workflowId', workflowId)
31+
32+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
33+
try {
34+
headers.Authorization = `Bearer ${await generateInternalToken(userId)}`
35+
} catch (_e) {
36+
// Swallow mint errors; the request then fails authentication and reports upstream.
37+
}
38+
39+
// boundary-raw-fetch: same-origin token route, authenticated by the internal JWT minted above
40+
const response = await fetch(url.toString(), {
41+
method: 'POST',
42+
headers,
43+
body: JSON.stringify({ credentialId, ...(workflowId ? { workflowId } : {}) }),
44+
})
45+
46+
if (!response.ok) {
47+
const errorText = await response.text()
48+
logger.error(`[${requestId}] Credential token request failed`, {
49+
status: response.status,
50+
credentialId,
51+
})
52+
let message = errorText
53+
try {
54+
const parsed = JSON.parse(errorText)
55+
if (parsed.error) message = parsed.error
56+
} catch {
57+
// Use raw text
58+
}
59+
throw new Error(message)
60+
}
61+
62+
const { accessToken } = (await response.json()) as { accessToken?: string }
63+
if (!accessToken) {
64+
throw new Error('Credential token response carried no access token')
65+
}
66+
return accessToken
67+
}

apps/sim/executor/utils/vertex-credential.test.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTokenIfNeeded } =
7-
vi.hoisted(() => ({
8-
mockGetCredentialActorContext: vi.fn(),
9-
mockGetServiceAccountToken: vi.fn(),
10-
mockRefreshTokenIfNeeded: vi.fn(),
11-
}))
6+
const {
7+
mockGetCredentialActorContext,
8+
mockGetServiceAccountToken,
9+
mockRefreshTokenIfNeeded,
10+
mockFetchCredentialAccessToken,
11+
} = vi.hoisted(() => ({
12+
mockGetCredentialActorContext: vi.fn(),
13+
mockGetServiceAccountToken: vi.fn(),
14+
mockRefreshTokenIfNeeded: vi.fn(),
15+
mockFetchCredentialAccessToken: vi.fn(),
16+
}))
1217

1318
vi.mock('@/lib/credentials/access', () => ({
1419
getCredentialActorContext: mockGetCredentialActorContext,
@@ -19,6 +24,9 @@ vi.mock('@/lib/oauth/credential-service', () => ({
1924
getServiceAccountToken: mockGetServiceAccountToken,
2025
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
2126
}))
27+
vi.mock('@/executor/utils/credential-token', () => ({
28+
fetchCredentialAccessToken: mockFetchCredentialAccessToken,
29+
}))
2230

2331
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
2432

@@ -95,3 +103,56 @@ describe('resolveVertexCredential workspace binding', () => {
95103
).rejects.toThrow('requires an authenticated user')
96104
})
97105
})
106+
107+
/**
108+
* This resolver runs inside the Trigger.dev worker, whose environment carries no OAuth
109+
* client config — an in-process refresh throws there once the stored token expires.
110+
*/
111+
describe('resolveVertexCredential OAuth branch', () => {
112+
const oauthContext = {
113+
credential: { id: 'cred-o', workspaceId: 'workspace-a', type: 'oauth', accountId: 'acct-1' },
114+
member: { id: 'member-1' },
115+
hasWorkspaceAccess: true,
116+
canWriteWorkspace: true,
117+
isAdmin: false,
118+
}
119+
120+
beforeEach(() => {
121+
vi.clearAllMocks()
122+
mockGetCredentialActorContext.mockResolvedValue(oauthContext)
123+
mockFetchCredentialAccessToken.mockResolvedValue('oauth-access-token')
124+
})
125+
126+
it('fetches the token from the app instead of refreshing in-process', async () => {
127+
await expect(
128+
resolveVertexCredential({
129+
credentialId: 'cred-o',
130+
actingUserId: 'user-1',
131+
workspaceId: 'workspace-a',
132+
workflowId: 'wf-1',
133+
})
134+
).resolves.toBe('oauth-access-token')
135+
136+
expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
137+
expect(mockFetchCredentialAccessToken).toHaveBeenCalledWith(
138+
expect.objectContaining({ credentialId: 'cred-o', userId: 'user-1', workflowId: 'wf-1' })
139+
)
140+
})
141+
142+
it('authorizes before requesting a token', async () => {
143+
mockGetCredentialActorContext.mockResolvedValue({
144+
...oauthContext,
145+
credential: { ...oauthContext.credential, workspaceId: 'workspace-b' },
146+
})
147+
148+
await expect(
149+
resolveVertexCredential({
150+
credentialId: 'cred-o',
151+
actingUserId: 'user-1',
152+
workspaceId: 'workspace-a',
153+
})
154+
).rejects.toThrow()
155+
156+
expect(mockFetchCredentialAccessToken).not.toHaveBeenCalled()
157+
})
158+
})

apps/sim/executor/utils/vertex-credential.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { db } from '@sim/db'
2-
import { account } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { eq } from 'drizzle-orm'
52
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
6-
import { getServiceAccountToken, refreshTokenIfNeeded } from '@/lib/oauth/credential-service'
3+
import { getServiceAccountToken } from '@/lib/oauth/credential-service'
4+
import { fetchCredentialAccessToken } from '@/executor/utils/credential-token'
75

86
const logger = createLogger('VertexCredential')
97

@@ -12,6 +10,8 @@ export interface ResolveVertexCredentialParams {
1210
actingUserId: string | undefined
1311
/** Workspace of the executing workflow. The credential must belong to it. */
1412
workspaceId: string | null | undefined
13+
/** Pins the token request to this workflow's workspace. */
14+
workflowId?: string
1515
callerLabel?: string
1616
}
1717

@@ -26,6 +26,7 @@ export async function resolveVertexCredential({
2626
credentialId,
2727
actingUserId,
2828
workspaceId,
29+
workflowId,
2930
callerLabel = 'vertex',
3031
}: ResolveVertexCredentialParams): Promise<string> {
3132
const requestId = `${callerLabel}-${Date.now()}`
@@ -64,16 +65,19 @@ export async function resolveVertexCredential({
6465
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
6566
}
6667

67-
const accountRow = await db.query.account.findFirst({
68-
where: eq(account.id, cred.accountId),
68+
/**
69+
* Fetched from the app rather than refreshed here: this runs inside the Trigger.dev
70+
* worker, whose environment carries no OAuth client config, so an in-process refresh
71+
* throws once the stored access token expires. The service-account branch above needs
72+
* no such config and stays in-process.
73+
*/
74+
const accessToken = await fetchCredentialAccessToken({
75+
requestId,
76+
credentialId,
77+
userId: actingUserId,
78+
workflowId,
6979
})
7080

71-
if (!accountRow) {
72-
throw new Error(`Vertex AI credential not found: ${credentialId}`)
73-
}
74-
75-
const { accessToken } = await refreshTokenIfNeeded(requestId, accountRow, cred.accountId)
76-
7781
if (!accessToken) {
7882
throw new Error('Failed to get Vertex AI access token')
7983
}

apps/sim/hooks/queries/tables.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,12 +1015,12 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
10151015
})
10161016
},
10171017
onMutate: async ({ rowId, data }) => {
1018-
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
1018+
await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) })
10191019

10201020
const previousQueries = queryClient.getQueriesData<
10211021
InfiniteData<TableRowsResponse, TableRowsPageParam>
10221022
>({
1023-
queryKey: tableKeys.rowsRoot(tableId),
1023+
queryKey: tableKeys.infiniteRowsRoot(tableId),
10241024
})
10251025

10261026
const groups =
@@ -1105,12 +1105,12 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon
11051105
})
11061106
},
11071107
onMutate: async ({ updates }) => {
1108-
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
1108+
await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) })
11091109

11101110
const previousQueries = queryClient.getQueriesData<
11111111
InfiniteData<TableRowsResponse, TableRowsPageParam>
11121112
>({
1113-
queryKey: tableKeys.rowsRoot(tableId),
1113+
queryKey: tableKeys.infiniteRowsRoot(tableId),
11141114
})
11151115

11161116
const updateMap = new Map(updates.map((u) => [u.rowId, u.data]))

0 commit comments

Comments
 (0)