diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts index 96b3375bfee..1602ef50373 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts @@ -53,7 +53,7 @@ export function useWorkflowResourceReplacementOptions({ ...environmentOptions, ...flattenWorkflowSearchReplacementOptions(oauthOptions), ...flattenWorkflowSearchReplacementOptions(knowledgeOptions), - ...flattenWorkflowSearchReplacementOptions(selectorOptions), + ...selectorOptions.flatMap((group) => group.data?.items ?? []), ...flattenWorkflowSearchReplacementOptions(tableOptions), ...flattenWorkflowSearchReplacementOptions(fileOptions), ...flattenWorkflowSearchReplacementOptions(mcpServerOptions), diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index 46feacf004a..0335a8a8563 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -22,6 +22,7 @@ import { createMcpToolId } from '@/lib/mcp/shared' import type { Credential } from '@/lib/oauth' import { executeSelectorRequest, + type LoadedSelectorOptions, loadAllSelectorOptions, } from '@/lib/selectors/client/execute-selector' import { projectSelectorContext } from '@/lib/selectors/context' @@ -58,6 +59,11 @@ export interface WorkflowSearchResolvedResource { inaccessible: boolean } +export interface WorkflowSearchSelectorReplacementOptions { + items: WorkflowSearchReplacementOption[] + truncated: boolean +} + export const workflowSearchReplaceKeys = { all: selectorQueryRoots.workflowSearchReplace, resourceDetails: () => [...workflowSearchReplaceKeys.all, 'resource-detail'] as const, @@ -476,7 +482,11 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) return { queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, ordinal, revision), - queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + queryFn: async ({ + signal, + }: { + signal: AbortSignal + }): Promise<{ option: SelectorOption | null; truncated: boolean }> => { if (manifest.supportsDetail) { const result = await executeSelectorRequest({ selectorKey, @@ -485,16 +495,22 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) request: { kind: 'detail', id: match.rawValue }, signal, }) - return result.kind === 'detail' ? result.item : null + return { + option: result.kind === 'detail' ? result.item : null, + truncated: false, + } } - const options = await loadAllSelectorOptions({ + const catalog = await loadAllSelectorOptions({ selectorKey, scope, context, signal, }) - return options.find((option) => option.id === match.rawValue) ?? null + return { + option: catalog.items.find((option) => option.id === match.rawValue) ?? null, + truncated: catalog.truncated, + } }, enabled: Boolean( selectorKey && @@ -503,13 +519,16 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) (manifest.classification === 'local' || scope) ), staleTime: manifest.staleTime ?? WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME, - select: (option: SelectorOption | null): WorkflowSearchResolvedResource => ({ - matchRawValue: match.rawValue, - resourceGroupKey: match.resource?.resourceGroupKey, - label: option?.label ?? match.rawValue, - resolved: Boolean(option), - inaccessible: false, - }), + select: ({ option, truncated }): WorkflowSearchResolvedResource => { + const unresolvedIncompleteCatalog = !option && truncated + return { + matchRawValue: match.rawValue, + resourceGroupKey: match.resource?.resourceGroupKey, + label: option?.label ?? match.rawValue, + resolved: Boolean(option), + inaccessible: unresolvedIncompleteCatalog, + } + }, } }), }) @@ -773,8 +792,11 @@ export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSea selectorKey && baseEnabled && (manifest.classification === 'local' || scope) ), staleTime: manifest.staleTime ?? WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME, - select: (options: SelectorOption[]): WorkflowSearchReplacementOption[] => - options.map((option) => ({ + select: ({ + items, + truncated, + }: LoadedSelectorOptions): WorkflowSearchSelectorReplacementOptions => ({ + items: items.map((option) => ({ kind: match.kind, value: option.id, label: option.label, @@ -782,6 +804,8 @@ export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSea selectorContext: context, resourceGroupKey: match.resource?.resourceGroupKey, })), + truncated, + }), } }), }) diff --git a/apps/sim/lib/api/contracts/selectors/execute.ts b/apps/sim/lib/api/contracts/selectors/execute.ts index 9eef6fbbafc..b0e6f0757a2 100644 --- a/apps/sim/lib/api/contracts/selectors/execute.ts +++ b/apps/sim/lib/api/contracts/selectors/execute.ts @@ -120,6 +120,7 @@ export const executeSelectorResponseSchema = z.discriminatedUnion('kind', [ .min(1) .max(16 * 1024) .optional(), + truncated: z.boolean().optional(), }) .strict(), z diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index bd5e9711c15..60f75064fa7 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -457,7 +457,7 @@ describe('executeSelector', () => { ) }) - it('logs truncation diagnostics server-side but strips them from the response', async () => { + it('exposes safe truncation state without diagnostic details', async () => { const { sanitizeSelectorResult } = await vi.importActual< typeof import('@/lib/selectors/server/sanitize') >('@/lib/selectors/server/sanitize') @@ -468,10 +468,15 @@ describe('executeSelector', () => { }) mocks.sanitize.mockImplementationOnce(sanitizeSelectorResult) - await expect(execute()).resolves.toEqual({ + const result = await execute() + + expect(result).toEqual({ kind: 'list', items: [{ id: 'label-1', label: 'Inbox' }], + truncated: true, }) + expect(result).not.toHaveProperty('diagnostics') + expect(JSON.stringify(result)).not.toContain('provider-cap') expect(mocks.logger.warn).toHaveBeenCalledWith( 'Selector provider result reached a configured cap', expect.objectContaining({ diff --git a/apps/sim/lib/selectors/client/execute-selector.test.ts b/apps/sim/lib/selectors/client/execute-selector.test.ts new file mode 100644 index 00000000000..4e9bfeedc41 --- /dev/null +++ b/apps/sim/lib/selectors/client/execute-selector.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) + +import { loadAllSelectorOptions } from '@/lib/selectors/client/execute-selector' +import { MAX_SELECTOR_OPTIONS, MAX_SELECTOR_PAGES } from '@/lib/selectors/limits' + +const input = { + selectorKey: 'bitbucket.workspaces' as const, + scope: { kind: 'workspace' as const, workspaceId: 'workspace-1' }, + context: { oauthCredential: 'credential-1' }, +} + +describe('loadAllSelectorOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('distinguishes a complete boundary-sized catalog from a capped page walk', async () => { + mockRequestJson.mockResolvedValueOnce({ + kind: 'list', + items: Array.from({ length: MAX_SELECTOR_OPTIONS }, (_, index) => ({ + id: `option-${index}`, + label: `Option ${index}`, + })), + }) + + const complete = await loadAllSelectorOptions(input) + + expect(complete.items).toHaveLength(MAX_SELECTOR_OPTIONS) + expect(complete.truncated).toBe(false) + + mockRequestJson.mockReset() + mockRequestJson.mockImplementation(async (...args: unknown[]) => { + const options = args[1] as { body: { request: { cursor?: string } } } + const page = Number(options.body.request.cursor ?? '0') + return { + kind: 'list', + items: [{ id: `page-${page}`, label: `Page ${page}` }], + nextCursor: String(page + 1), + } + }) + + const capped = await loadAllSelectorOptions(input) + + expect(mockRequestJson).toHaveBeenCalledTimes(MAX_SELECTOR_PAGES) + expect(capped.items).toHaveLength(MAX_SELECTOR_PAGES) + expect(capped.truncated).toBe(true) + }) +}) diff --git a/apps/sim/lib/selectors/client/execute-selector.ts b/apps/sim/lib/selectors/client/execute-selector.ts index dc36ff1e28a..3ff00336e9f 100644 --- a/apps/sim/lib/selectors/client/execute-selector.ts +++ b/apps/sim/lib/selectors/client/execute-selector.ts @@ -10,6 +10,7 @@ import { type SelectorKey, } from '@/lib/selectors/manifest' import type { + SafeSelectorOption, SelectorContext, SelectorExecutionResult, SelectorRequest, @@ -24,6 +25,11 @@ export interface ExecuteSelectorClientInput { signal?: AbortSignal } +export interface LoadedSelectorOptions { + items: SafeSelectorOption[] + truncated: boolean +} + export async function executeSelectorRequest( input: ExecuteSelectorClientInput ): Promise { @@ -46,14 +52,11 @@ export async function executeSelectorRequest( export async function loadAllSelectorOptions( input: Omit & { search?: string } -) { +): Promise { const supportsSearch = getSelectorManifestEntry(input.selectorKey).supportsSearch - const items: Array<{ - id: string - label: string - meta?: Record - }> = [] + const items: SafeSelectorOption[] = [] const seen = new Set() + let providerTruncated = false let cursor: string | undefined for (let page = 0; page < MAX_SELECTOR_PAGES; page += 1) { const result = await executeSelectorRequest({ @@ -65,14 +68,23 @@ export async function loadAllSelectorOptions( }, }) if (result.kind !== 'list') throw new Error('Selector returned an unexpected detail result') - for (const item of result.items) { + providerTruncated ||= result.truncated === true + for (const [index, item] of result.items.entries()) { if (seen.has(item.id)) continue seen.add(item.id) items.push(item) - if (items.length >= MAX_SELECTOR_OPTIONS) return items + if (items.length >= MAX_SELECTOR_OPTIONS) { + const omittedUniqueOption = result.items + .slice(index + 1) + .some((candidate) => !seen.has(candidate.id)) + return { + items, + truncated: providerTruncated || omittedUniqueOption || result.nextCursor !== undefined, + } + } } cursor = result.nextCursor - if (!cursor) break + if (!cursor) return { items, truncated: providerTruncated } } - return items + return { items, truncated: providerTruncated || cursor !== undefined } } diff --git a/apps/sim/lib/selectors/server/sanitize.ts b/apps/sim/lib/selectors/server/sanitize.ts index 11d827e7498..e12def7cfde 100644 --- a/apps/sim/lib/selectors/server/sanitize.ts +++ b/apps/sim/lib/selectors/server/sanitize.ts @@ -1,6 +1,9 @@ import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' -import type { SelectorProtectedValues } from '@/lib/selectors/server/types' +import type { + SelectorProtectedValues, + ServerSelectorExecutionResult, +} from '@/lib/selectors/server/types' import type { SafeOptionMeta, SafeOptionMetaValue, @@ -122,7 +125,7 @@ function sanitizeOption( } export function sanitizeSelectorResult( - result: SelectorExecutionResult, + result: ServerSelectorExecutionResult, protectedValues: SelectorProtectedValues, options?: SanitizeSelectorResultOptions ): SelectorExecutionResult { @@ -143,5 +146,6 @@ export function sanitizeSelectorResult( kind: 'list', items: result.items.map((item) => sanitizeOption(item, protectedValues)), ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result.diagnostics?.truncated ? { truncated: true } : {}), } } diff --git a/apps/sim/lib/selectors/types.ts b/apps/sim/lib/selectors/types.ts index 2980dd052fe..ad644b8946b 100644 --- a/apps/sim/lib/selectors/types.ts +++ b/apps/sim/lib/selectors/types.ts @@ -119,6 +119,7 @@ export type SelectorExecutionResult = kind: 'list' items: SafeSelectorOption[] nextCursor?: string + truncated?: boolean } | { kind: 'detail' diff --git a/apps/sim/lib/workflows/comparison/format-description.test.ts b/apps/sim/lib/workflows/comparison/format-description.test.ts index ea5c1bc19f5..b0c8d2f20e9 100644 --- a/apps/sim/lib/workflows/comparison/format-description.test.ts +++ b/apps/sim/lib/workflows/comparison/format-description.test.ts @@ -3,8 +3,9 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetBlock } = vi.hoisted(() => ({ +const { mockGetBlock, mockLoadAllSelectorOptions } = vi.hoisted(() => ({ mockGetBlock: vi.fn(), + mockLoadAllSelectorOptions: vi.fn(), })) vi.mock('@/lib/workflows/subblocks/visibility', () => ({ @@ -17,7 +18,7 @@ vi.mock('@/triggers/constants', () => ({ })) vi.mock('@/blocks/types', () => ({ - SELECTOR_TYPES_HYDRATION_REQUIRED: [], + SELECTOR_TYPES_HYDRATION_REQUIRED: ['channel-selector'], })) vi.mock('@/executor/constants', () => ({ @@ -37,7 +38,7 @@ vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ vi.mock('@/lib/selectors/client/execute-selector', () => ({ executeSelectorRequest: vi.fn(() => ({ kind: 'detail', item: null })), - loadAllSelectorOptions: vi.fn(() => []), + loadAllSelectorOptions: mockLoadAllSelectorOptions, })) import { WorkflowBuilder } from '@sim/testing' @@ -47,7 +48,11 @@ import { formatDiffSummaryForDescription, formatDiffSummaryForDescriptionAsync, } from '@/lib/workflows/comparison/describe' -import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values' +import { + formatValueForDisplay, + resolveFieldLabel, + resolveValueForDisplay, +} from '@/lib/workflows/comparison/resolve-values' function emptyDiffSummary(overrides: Partial = {}): WorkflowDiffSummary { return { @@ -72,6 +77,7 @@ function emptyDiffSummary(overrides: Partial = {}): Workflo beforeEach(() => { vi.clearAllMocks() + mockLoadAllSelectorOptions.mockResolvedValue({ items: [], truncated: false }) }) describe('resolveFieldLabel', () => { @@ -124,6 +130,36 @@ describe('formatValueForDisplay', () => { }) }) +describe('resolveValueForDisplay', () => { + it('preserves a raw selector ID when the loaded catalog is incomplete', async () => { + mockGetBlock.mockReturnValue({ + subBlocks: [ + { + id: 'channel', + title: 'Channel', + type: 'channel-selector', + selectorKey: 'slack.channels', + }, + ], + }) + mockLoadAllSelectorOptions.mockResolvedValue({ items: [], truncated: true }) + + const channelId = 'C12345678' + const result = await resolveValueForDisplay(channelId, { + blockType: 'slack', + subBlockId: 'channel', + workflowId: 'wf-1', + currentState: new WorkflowBuilder().build(), + }) + + expect(result).toEqual({ + original: channelId, + displayLabel: channelId, + resolved: false, + }) + }) +}) + describe('formatDiffSummaryForDescription', () => { it('returns no-changes message for empty diff', () => { const result = formatDiffSummaryForDescription(emptyDiffSummary()) diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 5bcc6d3a769..19913ba21c0 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -29,6 +29,11 @@ interface ResolvedValue { resolved: boolean } +interface ResolvedSelectorValue { + label: string | null + incomplete: boolean +} + /** * Context needed to resolve values for display */ @@ -87,7 +92,7 @@ async function resolveSelectorValue( selectorKey: SelectorKey, selectorContext: SelectorContext, scope: SelectorScope -): Promise { +): Promise { try { const manifest = getSelectorManifestEntry(selectorKey) @@ -99,20 +104,26 @@ async function resolveSelectorValue( request: { kind: 'detail', id: value }, }) if (result.kind === 'detail' && result.item?.label) { - return result.item.label + return { label: result.item.label, incomplete: false } } } - const options = await loadAllSelectorOptions({ + const catalog = await loadAllSelectorOptions({ selectorKey, scope, context: selectorContext, }) - const match = options.find((opt) => opt.id === value) - return match?.label ?? null + const match = catalog.items.find((option) => option.id === value) + const incomplete = !match && catalog.truncated + if (incomplete) { + logger.warn('Selector catalog was truncated before display label could be resolved', { + selectorKey, + }) + } + return { label: match?.label ?? null, incomplete } } catch { logger.warn('Failed to resolve selector display label', { selectorKey }) - return null + return { label: null, incomplete: false } } } @@ -267,9 +278,12 @@ export async function resolveValueForDisplay( const selectorContext = context.blockId ? extractSelectorContext(context.blockId, context.currentState, selectorKey, subBlockConfig) : projectSelectorContext(selectorKey, { mimeType: subBlockConfig.mimeType }) - const label = await resolveSelectorValue(value, selectorKey, selectorContext, scope) - if (label) { - return { original: value, displayLabel: label, resolved: true } + const selectorValue = await resolveSelectorValue(value, selectorKey, selectorContext, scope) + if (selectorValue.label) { + return { original: value, displayLabel: selectorValue.label, resolved: true } + } + if (selectorValue.incomplete) { + return { original: value, displayLabel: formatValueForDisplay(value), resolved: false } } } return { original: value, displayLabel: semanticFallback, resolved: true }