Skip to content

Commit 78780c9

Browse files
BillLeoutsakosvl346Bill Leoutsakoswaleedlatif1
authored
fix(selectors): expose incomplete loaded catalogs (#7338)
* fix(selectors): expose incomplete loaded catalogs * fix(selectors): cover incomplete comparison catalogs --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent e5a8852 commit 78780c9

10 files changed

Lines changed: 195 additions & 41 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/hooks/use-workflow-resource-replacement-options.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function useWorkflowResourceReplacementOptions({
5353
...environmentOptions,
5454
...flattenWorkflowSearchReplacementOptions(oauthOptions),
5555
...flattenWorkflowSearchReplacementOptions(knowledgeOptions),
56-
...flattenWorkflowSearchReplacementOptions(selectorOptions),
56+
...selectorOptions.flatMap((group) => group.data?.items ?? []),
5757
...flattenWorkflowSearchReplacementOptions(tableOptions),
5858
...flattenWorkflowSearchReplacementOptions(fileOptions),
5959
...flattenWorkflowSearchReplacementOptions(mcpServerOptions),

apps/sim/hooks/queries/workflow-search-replace.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { createMcpToolId } from '@/lib/mcp/shared'
2222
import type { Credential } from '@/lib/oauth'
2323
import {
2424
executeSelectorRequest,
25+
type LoadedSelectorOptions,
2526
loadAllSelectorOptions,
2627
} from '@/lib/selectors/client/execute-selector'
2728
import { projectSelectorContext } from '@/lib/selectors/context'
@@ -58,6 +59,11 @@ export interface WorkflowSearchResolvedResource {
5859
inaccessible: boolean
5960
}
6061

62+
export interface WorkflowSearchSelectorReplacementOptions {
63+
items: WorkflowSearchReplacementOption[]
64+
truncated: boolean
65+
}
66+
6167
export const workflowSearchReplaceKeys = {
6268
all: selectorQueryRoots.workflowSearchReplace,
6369
resourceDetails: () => [...workflowSearchReplaceKeys.all, 'resource-detail'] as const,
@@ -476,7 +482,11 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[])
476482

477483
return {
478484
queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, ordinal, revision),
479-
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<SelectorOption | null> => {
485+
queryFn: async ({
486+
signal,
487+
}: {
488+
signal: AbortSignal
489+
}): Promise<{ option: SelectorOption | null; truncated: boolean }> => {
480490
if (manifest.supportsDetail) {
481491
const result = await executeSelectorRequest({
482492
selectorKey,
@@ -485,16 +495,22 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[])
485495
request: { kind: 'detail', id: match.rawValue },
486496
signal,
487497
})
488-
return result.kind === 'detail' ? result.item : null
498+
return {
499+
option: result.kind === 'detail' ? result.item : null,
500+
truncated: false,
501+
}
489502
}
490503

491-
const options = await loadAllSelectorOptions({
504+
const catalog = await loadAllSelectorOptions({
492505
selectorKey,
493506
scope,
494507
context,
495508
signal,
496509
})
497-
return options.find((option) => option.id === match.rawValue) ?? null
510+
return {
511+
option: catalog.items.find((option) => option.id === match.rawValue) ?? null,
512+
truncated: catalog.truncated,
513+
}
498514
},
499515
enabled: Boolean(
500516
selectorKey &&
@@ -503,13 +519,16 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[])
503519
(manifest.classification === 'local' || scope)
504520
),
505521
staleTime: manifest.staleTime ?? WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME,
506-
select: (option: SelectorOption | null): WorkflowSearchResolvedResource => ({
507-
matchRawValue: match.rawValue,
508-
resourceGroupKey: match.resource?.resourceGroupKey,
509-
label: option?.label ?? match.rawValue,
510-
resolved: Boolean(option),
511-
inaccessible: false,
512-
}),
522+
select: ({ option, truncated }): WorkflowSearchResolvedResource => {
523+
const unresolvedIncompleteCatalog = !option && truncated
524+
return {
525+
matchRawValue: match.rawValue,
526+
resourceGroupKey: match.resource?.resourceGroupKey,
527+
label: option?.label ?? match.rawValue,
528+
resolved: Boolean(option),
529+
inaccessible: unresolvedIncompleteCatalog,
530+
}
531+
},
513532
}
514533
}),
515534
})
@@ -773,15 +792,20 @@ export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSea
773792
selectorKey && baseEnabled && (manifest.classification === 'local' || scope)
774793
),
775794
staleTime: manifest.staleTime ?? WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME,
776-
select: (options: SelectorOption[]): WorkflowSearchReplacementOption[] =>
777-
options.map((option) => ({
795+
select: ({
796+
items,
797+
truncated,
798+
}: LoadedSelectorOptions): WorkflowSearchSelectorReplacementOptions => ({
799+
items: items.map((option) => ({
778800
kind: match.kind,
779801
value: option.id,
780802
label: option.label,
781803
selectorKey,
782804
selectorContext: context,
783805
resourceGroupKey: match.resource?.resourceGroupKey,
784806
})),
807+
truncated,
808+
}),
785809
}
786810
}),
787811
})

apps/sim/lib/api/contracts/selectors/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export const executeSelectorResponseSchema = z.discriminatedUnion('kind', [
120120
.min(1)
121121
.max(16 * 1024)
122122
.optional(),
123+
truncated: z.boolean().optional(),
123124
})
124125
.strict(),
125126
z

apps/sim/lib/selectors/application/execute-selector.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ describe('executeSelector', () => {
457457
)
458458
})
459459

460-
it('logs truncation diagnostics server-side but strips them from the response', async () => {
460+
it('exposes safe truncation state without diagnostic details', async () => {
461461
const { sanitizeSelectorResult } = await vi.importActual<
462462
typeof import('@/lib/selectors/server/sanitize')
463463
>('@/lib/selectors/server/sanitize')
@@ -468,10 +468,15 @@ describe('executeSelector', () => {
468468
})
469469
mocks.sanitize.mockImplementationOnce(sanitizeSelectorResult)
470470

471-
await expect(execute()).resolves.toEqual({
471+
const result = await execute()
472+
473+
expect(result).toEqual({
472474
kind: 'list',
473475
items: [{ id: 'label-1', label: 'Inbox' }],
476+
truncated: true,
474477
})
478+
expect(result).not.toHaveProperty('diagnostics')
479+
expect(JSON.stringify(result)).not.toContain('provider-cap')
475480
expect(mocks.logger.warn).toHaveBeenCalledWith(
476481
'Selector provider result reached a configured cap',
477482
expect.objectContaining({
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockRequestJson } = vi.hoisted(() => ({
7+
mockRequestJson: vi.fn(),
8+
}))
9+
10+
vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson }))
11+
12+
import { loadAllSelectorOptions } from '@/lib/selectors/client/execute-selector'
13+
import { MAX_SELECTOR_OPTIONS, MAX_SELECTOR_PAGES } from '@/lib/selectors/limits'
14+
15+
const input = {
16+
selectorKey: 'bitbucket.workspaces' as const,
17+
scope: { kind: 'workspace' as const, workspaceId: 'workspace-1' },
18+
context: { oauthCredential: 'credential-1' },
19+
}
20+
21+
describe('loadAllSelectorOptions', () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks()
24+
})
25+
26+
it('distinguishes a complete boundary-sized catalog from a capped page walk', async () => {
27+
mockRequestJson.mockResolvedValueOnce({
28+
kind: 'list',
29+
items: Array.from({ length: MAX_SELECTOR_OPTIONS }, (_, index) => ({
30+
id: `option-${index}`,
31+
label: `Option ${index}`,
32+
})),
33+
})
34+
35+
const complete = await loadAllSelectorOptions(input)
36+
37+
expect(complete.items).toHaveLength(MAX_SELECTOR_OPTIONS)
38+
expect(complete.truncated).toBe(false)
39+
40+
mockRequestJson.mockReset()
41+
mockRequestJson.mockImplementation(async (...args: unknown[]) => {
42+
const options = args[1] as { body: { request: { cursor?: string } } }
43+
const page = Number(options.body.request.cursor ?? '0')
44+
return {
45+
kind: 'list',
46+
items: [{ id: `page-${page}`, label: `Page ${page}` }],
47+
nextCursor: String(page + 1),
48+
}
49+
})
50+
51+
const capped = await loadAllSelectorOptions(input)
52+
53+
expect(mockRequestJson).toHaveBeenCalledTimes(MAX_SELECTOR_PAGES)
54+
expect(capped.items).toHaveLength(MAX_SELECTOR_PAGES)
55+
expect(capped.truncated).toBe(true)
56+
})
57+
})

apps/sim/lib/selectors/client/execute-selector.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type SelectorKey,
1111
} from '@/lib/selectors/manifest'
1212
import type {
13+
SafeSelectorOption,
1314
SelectorContext,
1415
SelectorExecutionResult,
1516
SelectorRequest,
@@ -24,6 +25,11 @@ export interface ExecuteSelectorClientInput {
2425
signal?: AbortSignal
2526
}
2627

28+
export interface LoadedSelectorOptions {
29+
items: SafeSelectorOption[]
30+
truncated: boolean
31+
}
32+
2733
export async function executeSelectorRequest(
2834
input: ExecuteSelectorClientInput
2935
): Promise<SelectorExecutionResult> {
@@ -46,14 +52,11 @@ export async function executeSelectorRequest(
4652

4753
export async function loadAllSelectorOptions(
4854
input: Omit<ExecuteSelectorClientInput, 'request'> & { search?: string }
49-
) {
55+
): Promise<LoadedSelectorOptions> {
5056
const supportsSearch = getSelectorManifestEntry(input.selectorKey).supportsSearch
51-
const items: Array<{
52-
id: string
53-
label: string
54-
meta?: Record<string, string | number | boolean | null>
55-
}> = []
57+
const items: SafeSelectorOption[] = []
5658
const seen = new Set<string>()
59+
let providerTruncated = false
5760
let cursor: string | undefined
5861
for (let page = 0; page < MAX_SELECTOR_PAGES; page += 1) {
5962
const result = await executeSelectorRequest({
@@ -65,14 +68,23 @@ export async function loadAllSelectorOptions(
6568
},
6669
})
6770
if (result.kind !== 'list') throw new Error('Selector returned an unexpected detail result')
68-
for (const item of result.items) {
71+
providerTruncated ||= result.truncated === true
72+
for (const [index, item] of result.items.entries()) {
6973
if (seen.has(item.id)) continue
7074
seen.add(item.id)
7175
items.push(item)
72-
if (items.length >= MAX_SELECTOR_OPTIONS) return items
76+
if (items.length >= MAX_SELECTOR_OPTIONS) {
77+
const omittedUniqueOption = result.items
78+
.slice(index + 1)
79+
.some((candidate) => !seen.has(candidate.id))
80+
return {
81+
items,
82+
truncated: providerTruncated || omittedUniqueOption || result.nextCursor !== undefined,
83+
}
84+
}
7385
}
7486
cursor = result.nextCursor
75-
if (!cursor) break
87+
if (!cursor) return { items, truncated: providerTruncated }
7688
}
77-
return items
89+
return { items, truncated: providerTruncated || cursor !== undefined }
7890
}

apps/sim/lib/selectors/server/sanitize.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits'
22
import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors'
3-
import type { SelectorProtectedValues } from '@/lib/selectors/server/types'
3+
import type {
4+
SelectorProtectedValues,
5+
ServerSelectorExecutionResult,
6+
} from '@/lib/selectors/server/types'
47
import type {
58
SafeOptionMeta,
69
SafeOptionMetaValue,
@@ -122,7 +125,7 @@ function sanitizeOption(
122125
}
123126

124127
export function sanitizeSelectorResult(
125-
result: SelectorExecutionResult,
128+
result: ServerSelectorExecutionResult,
126129
protectedValues: SelectorProtectedValues,
127130
options?: SanitizeSelectorResultOptions
128131
): SelectorExecutionResult {
@@ -143,5 +146,6 @@ export function sanitizeSelectorResult(
143146
kind: 'list',
144147
items: result.items.map((item) => sanitizeOption(item, protectedValues)),
145148
...(result.nextCursor ? { nextCursor: result.nextCursor } : {}),
149+
...(result.diagnostics?.truncated ? { truncated: true } : {}),
146150
}
147151
}

apps/sim/lib/selectors/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export type SelectorExecutionResult =
119119
kind: 'list'
120120
items: SafeSelectorOption[]
121121
nextCursor?: string
122+
truncated?: boolean
122123
}
123124
| {
124125
kind: 'detail'

apps/sim/lib/workflows/comparison/format-description.test.ts

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

6-
const { mockGetBlock } = vi.hoisted(() => ({
6+
const { mockGetBlock, mockLoadAllSelectorOptions } = vi.hoisted(() => ({
77
mockGetBlock: vi.fn(),
8+
mockLoadAllSelectorOptions: vi.fn(),
89
}))
910

1011
vi.mock('@/lib/workflows/subblocks/visibility', () => ({
@@ -17,7 +18,7 @@ vi.mock('@/triggers/constants', () => ({
1718
}))
1819

1920
vi.mock('@/blocks/types', () => ({
20-
SELECTOR_TYPES_HYDRATION_REQUIRED: [],
21+
SELECTOR_TYPES_HYDRATION_REQUIRED: ['channel-selector'],
2122
}))
2223

2324
vi.mock('@/executor/constants', () => ({
@@ -37,7 +38,7 @@ vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
3738

3839
vi.mock('@/lib/selectors/client/execute-selector', () => ({
3940
executeSelectorRequest: vi.fn(() => ({ kind: 'detail', item: null })),
40-
loadAllSelectorOptions: vi.fn(() => []),
41+
loadAllSelectorOptions: mockLoadAllSelectorOptions,
4142
}))
4243

4344
import { WorkflowBuilder } from '@sim/testing'
@@ -47,7 +48,11 @@ import {
4748
formatDiffSummaryForDescription,
4849
formatDiffSummaryForDescriptionAsync,
4950
} from '@/lib/workflows/comparison/describe'
50-
import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values'
51+
import {
52+
formatValueForDisplay,
53+
resolveFieldLabel,
54+
resolveValueForDisplay,
55+
} from '@/lib/workflows/comparison/resolve-values'
5156

5257
function emptyDiffSummary(overrides: Partial<WorkflowDiffSummary> = {}): WorkflowDiffSummary {
5358
return {
@@ -72,6 +77,7 @@ function emptyDiffSummary(overrides: Partial<WorkflowDiffSummary> = {}): Workflo
7277

7378
beforeEach(() => {
7479
vi.clearAllMocks()
80+
mockLoadAllSelectorOptions.mockResolvedValue({ items: [], truncated: false })
7581
})
7682

7783
describe('resolveFieldLabel', () => {
@@ -124,6 +130,36 @@ describe('formatValueForDisplay', () => {
124130
})
125131
})
126132

133+
describe('resolveValueForDisplay', () => {
134+
it('preserves a raw selector ID when the loaded catalog is incomplete', async () => {
135+
mockGetBlock.mockReturnValue({
136+
subBlocks: [
137+
{
138+
id: 'channel',
139+
title: 'Channel',
140+
type: 'channel-selector',
141+
selectorKey: 'slack.channels',
142+
},
143+
],
144+
})
145+
mockLoadAllSelectorOptions.mockResolvedValue({ items: [], truncated: true })
146+
147+
const channelId = 'C12345678'
148+
const result = await resolveValueForDisplay(channelId, {
149+
blockType: 'slack',
150+
subBlockId: 'channel',
151+
workflowId: 'wf-1',
152+
currentState: new WorkflowBuilder().build(),
153+
})
154+
155+
expect(result).toEqual({
156+
original: channelId,
157+
displayLabel: channelId,
158+
resolved: false,
159+
})
160+
})
161+
})
162+
127163
describe('formatDiffSummaryForDescription', () => {
128164
it('returns no-changes message for empty diff', () => {
129165
const result = formatDiffSummaryForDescription(emptyDiffSummary())

0 commit comments

Comments
 (0)