Skip to content

Commit bc7e8ca

Browse files
committed
fix(files): address search review findings
1 parent d1132f2 commit bc7e8ca

14 files changed

Lines changed: 150 additions & 20 deletions

File tree

apps/docs/content/docs/integrations/file.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Search indexed text across active workspace files using literal smart-case subst
8989
|`text` | string | Matching line or bounded match-centered preview. |
9090
| `count` | number | Number of returned matching lines. |
9191
| `truncated` | boolean | Whether more matching lines exist beyond the configured hard cap. |
92-
| `complete` | boolean | Whether every current file revision is indexed without failures. |
92+
| `complete` | boolean | Whether indexing has no pending or failed current revisions; skipped and partial coverage is reported separately. |
9393
| `indexStatus` | object | Current workspace search-index coverage by file status. |
9494
|`readyFiles` | number | Files whose current revision is searchable. |
9595
|`pendingFiles` | number | Files still waiting to be indexed. |

apps/sim/blocks/blocks.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ describe.concurrent('Blocks Module', () => {
170170
expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([
171171
'file_read',
172172
'file_get_content',
173+
'file_search',
173174
'file_fetch',
174175
'file_write',
175176
'file_append',

apps/sim/blocks/blocks/file.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ describe('FileV5Block', () => {
8383
expect(maxResults?.value?.()).toBe('50')
8484
})
8585

86+
it.each(['10.5', '10results', '0', '201'])(
87+
'rejects invalid builder-configured search cap %s',
88+
(maxResults) => {
89+
expect(() =>
90+
buildParams({
91+
operation: 'file_search',
92+
query: 'needle',
93+
maxResults,
94+
})
95+
).toThrow('Maximum Results must be an integer between 1 and 200')
96+
}
97+
)
98+
8699
it('read returns only the files output (no redundant file)', () => {
87100
expect(FileV5Block.outputs.files).toBeDefined()
88101
expect(FileV5Block.outputs.contents).toBeDefined()

apps/sim/blocks/blocks/file.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,10 +1254,13 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
12541254
const operation = params.operation || 'file_read'
12551255

12561256
if (operation === 'file_search') {
1257-
const maxResults = Number.parseInt(String(params.maxResults ?? '50'), 10)
1257+
const maxResults = Number(params.maxResults ?? '50')
1258+
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 200) {
1259+
throw new Error('Maximum Results must be an integer between 1 and 200')
1260+
}
12581261
return {
12591262
query: params.query,
1260-
maxResults: Number.isNaN(maxResults) ? 50 : maxResults,
1263+
maxResults,
12611264
}
12621265
}
12631266

apps/sim/lib/internal/file/execute-tool.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal'
22
import { createLogger } from '@sim/logger'
3-
import { getErrorMessage } from '@sim/utils/errors'
3+
import { getErrorMessage, toError } from '@sim/utils/errors'
44
import { z } from 'zod'
55
import { fileParseContract } from '@/lib/api/contracts/storage-transfer'
66
import { fileManageContract } from '@/lib/api/contracts/tools/file'
@@ -184,6 +184,7 @@ export const executeFileTool: InternalToolOperationHandler = async (request) =>
184184
const message = getErrorMessage(error, 'Unknown error')
185185
logger.error('File operation dispatch failed', {
186186
error: isSearchFailure ? 'Workspace file search failed' : message,
187+
errorType: isSearchFailure ? toError(error).name : undefined,
187188
requestId: request.requestId,
188189
toolId: request.toolId,
189190
})

apps/sim/lib/workspace-files/application/operations.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('file operation registry', () => {
4444
expect(executorOperationIds).toEqual([
4545
'files.read_metadata',
4646
'files.read_content',
47+
'files.search_content',
4748
'files.download',
4849
'files.create',
4950
'files.update_content',

apps/sim/lib/workspace-files/search/indexing.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,49 @@ async function extractIndexText(
7070
}
7171
}
7272

73-
async function clearRevision(fileId: string, sourceContentUpdatedAt: Date): Promise<void> {
73+
async function clearRevision(
74+
workspaceId: string,
75+
fileId: string,
76+
sourceContentUpdatedAt: Date
77+
): Promise<void> {
7478
await db
7579
.delete(workspaceFileSearchSegment)
7680
.where(
7781
and(
82+
eq(workspaceFileSearchSegment.workspaceId, workspaceId),
7883
eq(workspaceFileSearchSegment.fileId, fileId),
7984
eq(workspaceFileSearchSegment.sourceContentUpdatedAt, sourceContentUpdatedAt)
8085
)
8186
)
8287
}
8388

89+
async function discardObsoleteRevision(options: {
90+
workspaceId: string
91+
fileId: string
92+
sourceContentUpdatedAt: Date
93+
}): Promise<void> {
94+
await db.transaction(async (tx) => {
95+
await tx
96+
.delete(workspaceFileSearchSegment)
97+
.where(
98+
and(
99+
eq(workspaceFileSearchSegment.workspaceId, options.workspaceId),
100+
eq(workspaceFileSearchSegment.fileId, options.fileId),
101+
eq(workspaceFileSearchSegment.sourceContentUpdatedAt, options.sourceContentUpdatedAt)
102+
)
103+
)
104+
await tx
105+
.delete(workspaceFileSearchIndex)
106+
.where(
107+
and(
108+
eq(workspaceFileSearchIndex.workspaceId, options.workspaceId),
109+
eq(workspaceFileSearchIndex.fileId, options.fileId),
110+
eq(workspaceFileSearchIndex.sourceContentUpdatedAt, options.sourceContentUpdatedAt)
111+
)
112+
)
113+
})
114+
}
115+
84116
async function markTerminal(options: {
85117
workspaceId: string
86118
fileId: string
@@ -114,6 +146,7 @@ async function markTerminal(options: {
114146
.delete(workspaceFileSearchSegment)
115147
.where(
116148
and(
149+
eq(workspaceFileSearchSegment.workspaceId, options.workspaceId),
117150
eq(workspaceFileSearchSegment.fileId, options.fileId),
118151
eq(workspaceFileSearchSegment.sourceContentUpdatedAt, options.sourceContentUpdatedAt)
119152
)
@@ -122,6 +155,7 @@ async function markTerminal(options: {
122155
.delete(workspaceFileSearchIndex)
123156
.where(
124157
and(
158+
eq(workspaceFileSearchIndex.workspaceId, options.workspaceId),
125159
eq(workspaceFileSearchIndex.fileId, options.fileId),
126160
eq(workspaceFileSearchIndex.sourceContentUpdatedAt, options.sourceContentUpdatedAt)
127161
)
@@ -234,12 +268,15 @@ export async function indexWorkspaceFileForSearch(
234268
throwOnError: true,
235269
})
236270
if (!file || !sameRevision(file.contentUpdatedAt, sourceContentUpdatedAt)) {
237-
await clearRevision(payload.fileId, sourceContentUpdatedAt)
271+
await discardObsoleteRevision({ ...payload, sourceContentUpdatedAt })
238272
return
239273
}
240274

241275
const [state] = await db
242-
.select({ status: workspaceFileSearchIndex.status })
276+
.select({
277+
status: workspaceFileSearchIndex.status,
278+
workspaceId: workspaceFileSearchIndex.workspaceId,
279+
})
243280
.from(workspaceFileSearchIndex)
244281
.where(
245282
and(
@@ -248,6 +285,10 @@ export async function indexWorkspaceFileForSearch(
248285
)
249286
)
250287
.limit(1)
288+
if (state && state.workspaceId !== payload.workspaceId) {
289+
await discardObsoleteRevision({ ...payload, sourceContentUpdatedAt })
290+
return
291+
}
251292
if (state?.status === 'ready' || state?.status === 'skipped') return
252293

253294
await db
@@ -270,7 +311,7 @@ export async function indexWorkspaceFileForSearch(
270311
updatedAt: new Date(),
271312
},
272313
})
273-
await clearRevision(payload.fileId, sourceContentUpdatedAt)
314+
await clearRevision(payload.workspaceId, payload.fileId, sourceContentUpdatedAt)
274315

275316
if (file.size > FILE_SEARCH_MAX_SOURCE_BYTES) {
276317
await markTerminal({
@@ -315,7 +356,7 @@ export async function indexWorkspaceFileForSearch(
315356
} catch (error) {
316357
if (signal.aborted) throw error
317358
if (isPayloadSizeLimitError(error)) {
318-
await clearRevision(payload.fileId, sourceContentUpdatedAt)
359+
await clearRevision(payload.workspaceId, payload.fileId, sourceContentUpdatedAt)
319360
await markTerminal({
320361
...payload,
321362
sourceContentUpdatedAt,
@@ -324,7 +365,7 @@ export async function indexWorkspaceFileForSearch(
324365
})
325366
return
326367
}
327-
await clearRevision(payload.fileId, sourceContentUpdatedAt)
368+
await clearRevision(payload.workspaceId, payload.fileId, sourceContentUpdatedAt)
328369
logger.error('Workspace file search indexing failed', {
329370
workspaceId: payload.workspaceId,
330371
fileId: payload.fileId,
@@ -340,7 +381,7 @@ export async function markWorkspaceFileSearchIndexFailed(
340381
): Promise<void> {
341382
const sourceContentUpdatedAt = new Date(payload.sourceContentUpdatedAt)
342383
if (Number.isNaN(sourceContentUpdatedAt.getTime())) return
343-
await clearRevision(payload.fileId, sourceContentUpdatedAt)
384+
await clearRevision(payload.workspaceId, payload.fileId, sourceContentUpdatedAt)
344385
await markTerminal({
345386
...payload,
346387
sourceContentUpdatedAt,

apps/sim/lib/workspace-files/search/text.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ describe('workspace file search text utilities', () => {
4343
expect(preview).not.toContain('�')
4444
})
4545

46+
it('maps case-folded offsets back to the original line', () => {
47+
const line = `${'İ'.repeat(1200)}needle${'x'.repeat(1200)}`
48+
const preview = createFileSearchPreview(line, 'needle', false)
49+
50+
expect(preview).toContain('needle')
51+
expect(Buffer.byteLength(preview, 'utf8')).toBeLessThanOrEqual(2048)
52+
})
53+
4654
it('shows omitted logical-line content beyond the selected segment', () => {
4755
expect(
4856
createFileSearchPreview('needle and nearby text', 'needle', false, 2048, {

apps/sim/lib/workspace-files/search/text.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,42 @@ export function truncateUtf8ToBytes(text: string, maxBytes: number): string {
110110
return encoded.subarray(0, end).toString('utf8')
111111
}
112112

113+
function findFileSearchMatchRange(
114+
line: string,
115+
query: string,
116+
caseSensitive: boolean
117+
): { start: number; end: number } {
118+
if (caseSensitive) {
119+
const start = Math.max(0, line.indexOf(query))
120+
return { start, end: Math.min(line.length, start + query.length) }
121+
}
122+
123+
const searchableLine = line.toLocaleLowerCase()
124+
const searchableQuery = query.toLocaleLowerCase()
125+
const foldedStart = searchableLine.indexOf(searchableQuery)
126+
if (foldedStart < 0) return { start: 0, end: Math.min(line.length, query.length) }
127+
128+
const originalStarts: number[] = []
129+
const originalEnds: number[] = []
130+
for (let offset = 0; offset < line.length; ) {
131+
const codePoint = line.codePointAt(offset)
132+
if (codePoint === undefined) break
133+
const character = String.fromCodePoint(codePoint)
134+
const foldedCharacter = character.toLocaleLowerCase()
135+
const end = offset + character.length
136+
for (let foldedOffset = 0; foldedOffset < foldedCharacter.length; foldedOffset += 1) {
137+
originalStarts.push(offset)
138+
originalEnds.push(end)
139+
}
140+
offset = end
141+
}
142+
143+
const foldedEnd = foldedStart + searchableQuery.length
144+
const start = originalStarts[foldedStart] ?? 0
145+
const end = originalEnds[foldedEnd - 1] ?? Math.min(line.length, start + query.length)
146+
return { start, end }
147+
}
148+
113149
export function createFileSearchPreview(
114150
line: string,
115151
query: string,
@@ -124,10 +160,7 @@ export function createFileSearchPreview(
124160
return `${boundaries.prefixOmitted ? '…' : ''}${line}${boundaries.suffixOmitted ? '…' : ''}`
125161
}
126162

127-
const searchableLine = caseSensitive ? line : line.toLocaleLowerCase()
128-
const searchableQuery = caseSensitive ? query : query.toLocaleLowerCase()
129-
const matchStart = Math.max(0, searchableLine.indexOf(searchableQuery))
130-
const matchEnd = Math.min(line.length, matchStart + query.length)
163+
const { start: matchStart, end: matchEnd } = findFileSearchMatchRange(line, query, caseSensitive)
131164
const leadingEllipsis = boundaries.prefixOmitted || matchStart > 0 ? '…' : ''
132165
const trailingEllipsis = boundaries.suffixOmitted || matchEnd < line.length ? '…' : ''
133166
const ellipsisBytes = Buffer.byteLength(leadingEllipsis + trailingEllipsis, 'utf8')

apps/sim/tools/file/search.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export const fileSearchTool: InternalToolConfig<FileSearchParams, FileSearchResp
8282
},
8383
complete: {
8484
type: 'boolean',
85-
description: 'Whether every current file revision is indexed without failures.',
85+
description:
86+
'Whether indexing has no pending or failed current revisions; skipped and partial coverage is reported separately.',
8687
},
8788
indexStatus: {
8889
type: 'object',

0 commit comments

Comments
 (0)