mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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
This commit is contained in:
+15
-1
@@ -2,6 +2,7 @@ import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Combobox as EditableCombobox } from '@sim/emcn'
|
||||
import { X } from '@sim/emcn/icons'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
useSelectorOptionMap,
|
||||
useSelectorOptions,
|
||||
} from '@/hooks/selectors/use-selector-query'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
interface SelectorComboboxProps {
|
||||
blockId: string
|
||||
@@ -63,6 +65,18 @@ export function SelectorCombobox({
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [multiInput, setMultiInput] = useState('')
|
||||
/**
|
||||
* The search reaches the provider, so it is debounced before it enters the query key
|
||||
* rather than on every keystroke — several of these selectors are rate-limited by the
|
||||
* provider. Only the query sees the debounced value; the input stays on `searchTerm`.
|
||||
*
|
||||
* Clearing is not debounced: a multi-select pick resets the term so the next choice comes
|
||||
* from the full list, and waiting out the delay would leave the previous filtered results
|
||||
* on screen. This mirrors the shared debounced-search setter, which also flushes empty.
|
||||
*/
|
||||
const trimmedSearch = searchTerm.trim()
|
||||
const debouncedSearch = useDebounce(trimmedSearch, SEARCH_DEBOUNCE_MS)
|
||||
const activeSearch = trimmedSearch === '' ? '' : debouncedSearch
|
||||
const {
|
||||
data: options = [],
|
||||
isLoading,
|
||||
@@ -70,7 +84,7 @@ export function SelectorCombobox({
|
||||
error,
|
||||
} = useSelectorOptions(selectorKey, {
|
||||
context: selectorContext,
|
||||
search: allowSearch ? searchTerm : undefined,
|
||||
search: allowSearch ? activeSearch : undefined,
|
||||
})
|
||||
const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
|
||||
context: selectorContext,
|
||||
|
||||
@@ -2543,6 +2543,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
credentialId: providerRequest.vertexCredential,
|
||||
actingUserId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
callerLabel: 'vertex-agent',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
|
||||
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
|
||||
|
||||
vi.mock('@/executor/utils/credential-token', () => ({
|
||||
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/access', () => ({
|
||||
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
|
||||
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
|
||||
|
||||
@@ -182,6 +182,7 @@ export class EvaluatorBlockHandler implements BlockHandler {
|
||||
credentialId: evaluatorConfig.vertexCredential,
|
||||
actingUserId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
callerLabel: 'vertex-evaluator',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
|
||||
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
|
||||
vi.mock('@/executor/utils/credential-token', () => ({
|
||||
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/access', () => ({
|
||||
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
|
||||
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
|
||||
|
||||
@@ -117,6 +117,7 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
credentialId: routerConfig.vertexCredential,
|
||||
actingUserId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
callerLabel: 'vertex-router',
|
||||
})
|
||||
}
|
||||
@@ -279,6 +280,7 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
credentialId: routerConfig.vertexCredential,
|
||||
actingUserId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
callerLabel: 'vertex-router',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('ExecutorCredentialToken')
|
||||
|
||||
/**
|
||||
* Fetches a credential's access token from the app rather than resolving it here.
|
||||
*
|
||||
* Refreshing an OAuth token needs the provider's client id and secret, read through
|
||||
* `requireOAuthClientCapability`, which THROWS when they are absent. Only the app
|
||||
* container loads those (from `SIM_ENV_SECRET_ID`); workflow execution runs in a
|
||||
* Trigger.dev worker whose environment does not carry them. Resolving in-process there
|
||||
* turns every credential whose access token has expired into a refresh failure, and a
|
||||
* still-valid token hides it until the token lapses.
|
||||
*
|
||||
* See `.claude/rules/sim-architecture.md`, "The app/worker runtime boundary".
|
||||
*
|
||||
* The route authorizes the credential itself, so this never widens access.
|
||||
*/
|
||||
export async function fetchCredentialAccessToken(params: {
|
||||
requestId: string
|
||||
credentialId: string
|
||||
userId: string
|
||||
workflowId?: string
|
||||
}): Promise<string> {
|
||||
const { requestId, credentialId, userId, workflowId } = params
|
||||
|
||||
const url = new URL('/api/auth/oauth/token', getInternalApiBaseUrl())
|
||||
if (workflowId) url.searchParams.set('workflowId', workflowId)
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
try {
|
||||
headers.Authorization = `Bearer ${await generateInternalToken(userId)}`
|
||||
} catch (_e) {
|
||||
// Swallow mint errors; the request then fails authentication and reports upstream.
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: same-origin token route, authenticated by the internal JWT minted above
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ credentialId, ...(workflowId ? { workflowId } : {}) }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`[${requestId}] Credential token request failed`, {
|
||||
status: response.status,
|
||||
credentialId,
|
||||
})
|
||||
let message = errorText
|
||||
try {
|
||||
const parsed = JSON.parse(errorText)
|
||||
if (parsed.error) message = parsed.error
|
||||
} catch {
|
||||
// Use raw text
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const { accessToken } = (await response.json()) as { accessToken?: string }
|
||||
if (!accessToken) {
|
||||
throw new Error('Credential token response carried no access token')
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
@@ -3,12 +3,17 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTokenIfNeeded } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetCredentialActorContext: vi.fn(),
|
||||
mockGetServiceAccountToken: vi.fn(),
|
||||
mockRefreshTokenIfNeeded: vi.fn(),
|
||||
}))
|
||||
const {
|
||||
mockGetCredentialActorContext,
|
||||
mockGetServiceAccountToken,
|
||||
mockRefreshTokenIfNeeded,
|
||||
mockFetchCredentialAccessToken,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetCredentialActorContext: vi.fn(),
|
||||
mockGetServiceAccountToken: vi.fn(),
|
||||
mockRefreshTokenIfNeeded: vi.fn(),
|
||||
mockFetchCredentialAccessToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/access', () => ({
|
||||
getCredentialActorContext: mockGetCredentialActorContext,
|
||||
@@ -19,6 +24,9 @@ vi.mock('@/lib/oauth/credential-service', () => ({
|
||||
getServiceAccountToken: mockGetServiceAccountToken,
|
||||
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
|
||||
}))
|
||||
vi.mock('@/executor/utils/credential-token', () => ({
|
||||
fetchCredentialAccessToken: mockFetchCredentialAccessToken,
|
||||
}))
|
||||
|
||||
import { resolveVertexCredential } from '@/executor/utils/vertex-credential'
|
||||
|
||||
@@ -95,3 +103,56 @@ describe('resolveVertexCredential workspace binding', () => {
|
||||
).rejects.toThrow('requires an authenticated user')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* This resolver runs inside the Trigger.dev worker, whose environment carries no OAuth
|
||||
* client config — an in-process refresh throws there once the stored token expires.
|
||||
*/
|
||||
describe('resolveVertexCredential OAuth branch', () => {
|
||||
const oauthContext = {
|
||||
credential: { id: 'cred-o', workspaceId: 'workspace-a', type: 'oauth', accountId: 'acct-1' },
|
||||
member: { id: 'member-1' },
|
||||
hasWorkspaceAccess: true,
|
||||
canWriteWorkspace: true,
|
||||
isAdmin: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetCredentialActorContext.mockResolvedValue(oauthContext)
|
||||
mockFetchCredentialAccessToken.mockResolvedValue('oauth-access-token')
|
||||
})
|
||||
|
||||
it('fetches the token from the app instead of refreshing in-process', async () => {
|
||||
await expect(
|
||||
resolveVertexCredential({
|
||||
credentialId: 'cred-o',
|
||||
actingUserId: 'user-1',
|
||||
workspaceId: 'workspace-a',
|
||||
workflowId: 'wf-1',
|
||||
})
|
||||
).resolves.toBe('oauth-access-token')
|
||||
|
||||
expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
|
||||
expect(mockFetchCredentialAccessToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ credentialId: 'cred-o', userId: 'user-1', workflowId: 'wf-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('authorizes before requesting a token', async () => {
|
||||
mockGetCredentialActorContext.mockResolvedValue({
|
||||
...oauthContext,
|
||||
credential: { ...oauthContext.credential, workspaceId: 'workspace-b' },
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveVertexCredential({
|
||||
credentialId: 'cred-o',
|
||||
actingUserId: 'user-1',
|
||||
workspaceId: 'workspace-a',
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(mockFetchCredentialAccessToken).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import { getServiceAccountToken, refreshTokenIfNeeded } from '@/lib/oauth/credential-service'
|
||||
import { getServiceAccountToken } from '@/lib/oauth/credential-service'
|
||||
import { fetchCredentialAccessToken } from '@/executor/utils/credential-token'
|
||||
|
||||
const logger = createLogger('VertexCredential')
|
||||
|
||||
@@ -12,6 +10,8 @@ export interface ResolveVertexCredentialParams {
|
||||
actingUserId: string | undefined
|
||||
/** Workspace of the executing workflow. The credential must belong to it. */
|
||||
workspaceId: string | null | undefined
|
||||
/** Pins the token request to this workflow's workspace. */
|
||||
workflowId?: string
|
||||
callerLabel?: string
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function resolveVertexCredential({
|
||||
credentialId,
|
||||
actingUserId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
callerLabel = 'vertex',
|
||||
}: ResolveVertexCredentialParams): Promise<string> {
|
||||
const requestId = `${callerLabel}-${Date.now()}`
|
||||
@@ -64,16 +65,19 @@ export async function resolveVertexCredential({
|
||||
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
|
||||
}
|
||||
|
||||
const accountRow = await db.query.account.findFirst({
|
||||
where: eq(account.id, cred.accountId),
|
||||
/**
|
||||
* Fetched from the app rather than refreshed here: this runs inside the Trigger.dev
|
||||
* worker, whose environment carries no OAuth client config, so an in-process refresh
|
||||
* throws once the stored access token expires. The service-account branch above needs
|
||||
* no such config and stays in-process.
|
||||
*/
|
||||
const accessToken = await fetchCredentialAccessToken({
|
||||
requestId,
|
||||
credentialId,
|
||||
userId: actingUserId,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
if (!accountRow) {
|
||||
throw new Error(`Vertex AI credential not found: ${credentialId}`)
|
||||
}
|
||||
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, accountRow, cred.accountId)
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to get Vertex AI access token')
|
||||
}
|
||||
|
||||
@@ -1015,12 +1015,12 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
|
||||
})
|
||||
},
|
||||
onMutate: async ({ rowId, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
|
||||
await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) })
|
||||
|
||||
const previousQueries = queryClient.getQueriesData<
|
||||
InfiniteData<TableRowsResponse, TableRowsPageParam>
|
||||
>({
|
||||
queryKey: tableKeys.rowsRoot(tableId),
|
||||
queryKey: tableKeys.infiniteRowsRoot(tableId),
|
||||
})
|
||||
|
||||
const groups =
|
||||
@@ -1105,12 +1105,12 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon
|
||||
})
|
||||
},
|
||||
onMutate: async ({ updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
|
||||
await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) })
|
||||
|
||||
const previousQueries = queryClient.getQueriesData<
|
||||
InfiniteData<TableRowsResponse, TableRowsPageParam>
|
||||
>({
|
||||
queryKey: tableKeys.rowsRoot(tableId),
|
||||
queryKey: tableKeys.infiniteRowsRoot(tableId),
|
||||
})
|
||||
|
||||
const updateMap = new Map(updates.map((u) => [u.rowId, u.data]))
|
||||
|
||||
Reference in New Issue
Block a user