Skip to content

Commit 2b651c9

Browse files
committed
improvement(file-search): publish complete indexes in bounded text chunks
1 parent bfaeaba commit 2b651c9

43 files changed

Lines changed: 30372 additions & 846 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Extract the text content of workspace files selected directly, identified by can
7777

7878
### File Search
7979

80-
Search the indexed text of active workspace files for lines matching a query, and return each matching line once with its file ID and line number. By default the query is a regular expression; in exact mode it is matched verbatim and metacharacters are literal. Coverage is what the index currently holds. A term that is not found is only authoritative when "complete" is true AND "indexStatus" reports no skipped or partial files; otherwise it is unknown rather than absent, so re-check before creating something on the assumption it is missing. Narrow the search with folderPaths to confine it to one or more folder trees, which also narrows "indexStatus" to those trees.
80+
Search the indexed text of active workspace files for lines matching a query, and return each matching line once with its file ID and line number. By default the query is a regular expression; in exact mode it is matched verbatim and metacharacters are literal. Coverage is what the index currently holds. A term that is not found is only authoritative when "complete" is true AND "indexStatus" reports no skipped files; otherwise it is unknown rather than absent, so re-check before creating something on the assumption it is missing. Narrow the search with folderPaths to confine it to one or more folder trees, which also narrows "indexStatus" to those trees.
8181

8282
#### Input
8383

@@ -99,13 +99,13 @@ Search the indexed text of active workspace files for lines matching a query, an
9999
| ↳ `text` | string | Matching line or bounded match-centered preview. |
100100
| `count` | number | Number of returned matching lines. |
101101
| `truncated` | boolean | Whether more matching lines exist beyond the configured hard cap. |
102-
| `complete` | boolean | Whether indexing has no pending or failed current revisions; skipped and partial coverage is reported separately. |
102+
| `complete` | boolean | Whether indexing has no pending or failed current revisions; excluded files are reported separately. |
103103
| `indexStatus` | object | Current workspace search-index coverage by file status. |
104-
| ↳ `readyFiles` | number | Files whose current revision is searchable. |
104+
| ↳ `readyFiles` | number | Files whose entire current extracted text is searchable. |
105105
| ↳ `pendingFiles` | number | Files still waiting to be indexed. |
106106
| ↳ `failedFiles` | number | Files whose current indexing attempt failed. |
107-
| ↳ `skippedFiles` | number | Files intentionally excluded because they are unsupported or oversized. |
108-
| ↳ `partialFiles` | number | Searchable files whose extracted text was truncated by the parser or cap. |
107+
| ↳ `skippedFiles` | number | Files excluded in full because they are oversized, unsupported, or cannot be completely extracted. |
108+
| ↳ `partialFiles` | number | Always zero; retained for compatibility. Files are never partially indexed. |
109109

110110
### File Fetch
111111

@@ -380,4 +380,14 @@ Move an existing workspace file into a folder. Moves the file itself; use Move F
380380
| `fileId` | string | The file that was moved. |
381381
| `folderPath` | string | The folder the file now lives in. |
382382

383+
{/* MANUAL-CONTENT-START:search_limits */}
384+
## Search coverage and limits
383385

386+
Search indexes the complete extracted text of each eligible file. The source file and its extracted UTF-8 text must each be at most **25 MiB (26,214,400 bytes)**. Oversized files, unsupported binary formats, and documents that cannot be completely extracted within parser safety limits are excluded as whole files and counted in `skippedFiles`. Search never indexes only the first rows, lines, or characters. CSV search preserves decoded source text; spreadsheet search includes populated cells beyond the preview limits. Image-only documents require searchable text; search does not perform OCR.
387+
388+
Existing parser safeguards also apply to complete extraction. PDFs allow at most 10,000 pages, 20 MiB of extracted text, 250,000 characters on one page, and 60 seconds of extraction. Office archives allow at most 150 MiB expanded in total, 64 MiB for one archive entry, and 10,000 entries; malformed archives and excessive compression ratios are rejected. Hitting any of these limits excludes the whole file from search.
389+
390+
Updates are indexed asynchronously. `pendingFiles` and `failedFiles` indicate revisions that are not yet searchable; a new revision becomes searchable only when its full index is ready. An empty result proves absence only within the searched scope when `complete` is true and `skippedFiles` is zero.
391+
392+
Regex is evaluated against complete logical lines, including long lines, and cannot span line breaks. Returned lines may use a shortened preview. The result limit (up to 200 lines) and a 10-second query deadline limit an individual request, not the amount of text indexed. An expensive query fails explicitly instead of returning an apparently complete subset; narrow its literal text or folder scope and retry.
393+
{/* MANUAL-CONTENT-END */}

‎apps/sim/lib/copilot/tools/server/files/doc-compile.ts‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { sha256Hex } from '@sim/security/hash'
44
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
55
import {
6+
type CompiledDocReadOptions,
67
loadCompiledDoc,
78
loadPublishedCompiledDoc,
89
publishCompiledDocArtifact,
@@ -736,26 +737,32 @@ export async function loadCompiledDocByExt(
736737
workspaceId: string,
737738
source: string,
738739
ext: string,
739-
options: {
740+
options: CompiledDocReadOptions & {
740741
allowLegacyReferencedArtifact?: boolean
741742
allowPublishedReferencedArtifact?: boolean
742743
filePrincipal?: Principal
743744
} = {}
744745
): Promise<{ buffer: Buffer; contentType: string } | null> {
745746
const fmt = await getE2BDocFormat(`x.${ext}`)
746747
if (!fmt) return null
748+
const readOptions: CompiledDocReadOptions = { maxBytes: options.maxBytes, signal: options.signal }
747749
const referencedFileIds = collectReferencedFileIds(source)
748750
if (!options.filePrincipal) {
749751
if (referencedFileIds.size === 0) {
750-
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
752+
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
751753
return buffer ? { buffer, contentType: fmt.contentType } : null
752754
}
753755
if (options.allowPublishedReferencedArtifact) {
754-
const publishedBuffer = await loadPublishedCompiledDoc(workspaceId, source, fmt.ext)
756+
const publishedBuffer = await loadPublishedCompiledDoc(
757+
workspaceId,
758+
source,
759+
fmt.ext,
760+
readOptions
761+
)
755762
if (publishedBuffer) return { buffer: publishedBuffer, contentType: fmt.contentType }
756763
}
757764
if (!options.allowLegacyReferencedArtifact) return null
758-
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
765+
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
759766
return legacyBuffer ? { buffer: legacyBuffer, contentType: fmt.contentType } : null
760767
}
761768
const referencedImages = await resolveReferencedImages(
@@ -768,11 +775,12 @@ export async function loadCompiledDocByExt(
768775
workspaceId,
769776
source,
770777
fmt.ext,
771-
referencedImages.artifactIdentity
778+
referencedImages.artifactIdentity,
779+
readOptions
772780
)
773781
if (buffer) return { buffer, contentType: fmt.contentType }
774782
if (referencedImages.artifactIdentity && options.allowLegacyReferencedArtifact) {
775-
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
783+
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
776784
if (legacyBuffer) return { buffer: legacyBuffer, contentType: fmt.contentType }
777785
}
778786
return null
@@ -799,7 +807,8 @@ export type ServableDoc =
799807
export async function resolveServableDoc(
800808
workspaceId: string,
801809
storedBytes: Buffer,
802-
fileName: string
810+
fileName: string,
811+
options: CompiledDocReadOptions = {}
803812
): Promise<ServableDoc> {
804813
const fmt = await getE2BDocFormat(fileName)
805814
if (!fmt) return { kind: 'passthrough' }
@@ -810,7 +819,7 @@ export async function resolveServableDoc(
810819
workspaceId,
811820
storedBytes.toString('utf-8'),
812821
fmt.ext,
813-
{ allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true }
822+
{ ...options, allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true }
814823
)
815824
return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' }
816825
} catch (error) {

‎apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({
1616
}))
1717

1818
import {
19+
loadCompiledDoc,
1920
loadPublishedCompiledDoc,
2021
storeCompiledDoc,
2122
} from '@/lib/copilot/tools/server/files/doc-compiled-store'
@@ -105,6 +106,35 @@ describe('compiled document publication', () => {
105106
)
106107
})
107108

109+
it('applies the caller budget and cancellation to both pointer and artifact downloads', async () => {
110+
const signal = new AbortController().signal
111+
const maxBytes = 25 * 1024 * 1024
112+
mockHeadObject.mockResolvedValue({ size: 1 })
113+
mockDownloadFile
114+
.mockResolvedValueOnce(
115+
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'x'.repeat(8192) }))
116+
)
117+
.mockResolvedValueOnce(Buffer.from('%PDF-artifact'))
118+
119+
await loadPublishedCompiledDoc('workspace-1', 'source', 'pdf', { maxBytes, signal })
120+
121+
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
122+
for (const [options] of mockDownloadFile.mock.calls) {
123+
expect(options).toMatchObject({ maxBytes, signal })
124+
}
125+
})
126+
127+
it('does not turn an interrupted artifact download into a cache miss', async () => {
128+
const controller = new AbortController()
129+
mockDownloadFile.mockImplementationOnce(async () => {
130+
controller.abort()
131+
throw new Error('download interrupted')
132+
})
133+
await expect(
134+
loadCompiledDoc('workspace-1', 'source', 'pdf', undefined, { signal: controller.signal })
135+
).rejects.toMatchObject({ name: 'AbortError' })
136+
})
137+
108138
it('still reports a missing artifact as not yet built', async () => {
109139
mockHeadObject.mockResolvedValue({ size: 1 })
110140
mockDownloadFile.mockResolvedValueOnce(

‎apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts‎

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,32 @@ function publishedArtifactPointerKey(workspaceId: string, source: string, ext: s
3737
return `copilot-doc-compiled/${workspaceId}/${sourceHash}.${ext}.published.json`
3838
}
3939

40+
export interface CompiledDocReadOptions {
41+
maxBytes?: number
42+
signal?: AbortSignal
43+
}
44+
4045
interface PublishedArtifactPointer {
4146
version: 1
4247
referencedInputIdentity: string
4348
}
4449

45-
async function loadPublishedArtifactPointer(key: string): Promise<PublishedArtifactPointer | null> {
50+
async function loadPublishedArtifactPointer(
51+
key: string,
52+
options: CompiledDocReadOptions = {}
53+
): Promise<PublishedArtifactPointer | null> {
54+
options.signal?.throwIfAborted()
4655
const stored = await headObject(key, 'copilot')
4756
if (!stored) return null
48-
const encoded = await downloadFile({ key, context: 'copilot' })
57+
const encoded = await downloadFile({
58+
key,
59+
context: 'copilot',
60+
maxBytes: Math.min(
61+
options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES,
62+
MAX_BUFFERED_TRANSFER_BYTES
63+
),
64+
signal: options.signal,
65+
})
4966

5067
let decoded: unknown
5168
try {
@@ -75,11 +92,8 @@ async function loadPublishedArtifactPointer(key: string): Promise<PublishedArtif
7592
* about the size of this. Bounding it here rather than on the finished response is
7693
* what keeps an oversized artifact from being materialized before it is refused.
7794
*
78-
* The bound is the WIDEST ceiling any consumer of this funnel allows, because it is a
79-
* memory backstop and not a policy: a consumer that permits less enforces its own
80-
* limit on what it got back (the workspace download path holds artifacts to
81-
* `MAX_RENDERED_DOCUMENT_BYTES`, half of this). Using the tighter figure here instead
82-
* would reject artifacts the serving routes are willing to return.
95+
* The default is the widest ceiling consumers allow. Callers can tighten it before
96+
* downloading, so indexing does not materialize an artifact it will immediately reject.
8397
*
8498
* A size breach is rethrown rather than folded into `null`: null means "not built
8599
* yet", which callers answer with "still being prepared, try again", and an artifact
@@ -89,12 +103,22 @@ export async function loadCompiledDoc(
89103
workspaceId: string,
90104
source: string,
91105
ext: string,
92-
referencedInputIdentity?: string
106+
referencedInputIdentity?: string,
107+
options: CompiledDocReadOptions = {}
93108
): Promise<Buffer | null> {
94109
const key = compiledArtifactKey(workspaceId, source, ext, referencedInputIdentity)
95110
try {
96-
return await downloadFile({ key, context: 'copilot', maxBytes: MAX_BUFFERED_TRANSFER_BYTES })
111+
return await downloadFile({
112+
key,
113+
context: 'copilot',
114+
maxBytes: Math.min(
115+
options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES,
116+
MAX_BUFFERED_TRANSFER_BYTES
117+
),
118+
signal: options.signal,
119+
})
97120
} catch (error) {
121+
options.signal?.throwIfAborted()
98122
if (isPayloadSizeLimitError(error)) throw error
99123
return null
100124
}
@@ -140,12 +164,19 @@ export async function publishCompiledDocArtifact(
140164
export async function loadPublishedCompiledDoc(
141165
workspaceId: string,
142166
source: string,
143-
ext: string
167+
ext: string,
168+
options: CompiledDocReadOptions = {}
144169
): Promise<Buffer | null> {
145170
const key = publishedArtifactPointerKey(workspaceId, source, ext)
146-
const pointer = await loadPublishedArtifactPointer(key)
171+
const pointer = await loadPublishedArtifactPointer(key, options)
147172
if (!pointer) return null
148-
const artifact = await loadCompiledDoc(workspaceId, source, ext, pointer.referencedInputIdentity)
173+
const artifact = await loadCompiledDoc(
174+
workspaceId,
175+
source,
176+
ext,
177+
pointer.referencedInputIdentity,
178+
options
179+
)
149180
if (!artifact) throw new Error(`Published compiled document artifact is missing: ${key}`)
150181
return artifact
151182
}

‎apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ describe('resolveServableDocBytes', () => {
9696
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
9797
WORKSPACE_ID,
9898
PDF_SOURCE.toString('utf-8'),
99-
'pdf'
99+
'pdf',
100+
undefined,
101+
{ maxBytes: undefined, signal: undefined }
100102
)
101103
expect(mockLoadCompiledDoc).toHaveBeenCalledTimes(1)
102104
})
@@ -123,7 +125,9 @@ describe('resolveServableDocBytes', () => {
123125
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
124126
WORKSPACE_ID,
125127
PDF_SOURCE.toString('utf-8'),
126-
'pdf'
128+
'pdf',
129+
undefined,
130+
{ maxBytes: undefined, signal: undefined }
127131
)
128132
})
129133

@@ -202,7 +206,13 @@ describe('resolveServableDocBytes', () => {
202206
buffer: legacyArtifact,
203207
contentType: 'application/pdf',
204208
})
205-
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(WORKSPACE_ID, source.toString('utf-8'), 'pdf')
209+
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
210+
WORKSPACE_ID,
211+
source.toString('utf-8'),
212+
'pdf',
213+
undefined,
214+
{ maxBytes: undefined, signal: undefined }
215+
)
206216
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
207217
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
208218
expect(mockStoreCompiledDoc).not.toHaveBeenCalled()
@@ -221,7 +231,8 @@ describe('resolveServableDocBytes', () => {
221231
expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith(
222232
WORKSPACE_ID,
223233
source.toString('utf-8'),
224-
'pdf'
234+
'pdf',
235+
{ maxBytes: undefined, signal: undefined }
225236
)
226237
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
227238
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
@@ -242,7 +253,8 @@ describe('resolveServableDocBytes', () => {
242253
expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith(
243254
WORKSPACE_ID,
244255
source.toString('utf-8'),
245-
'pdf'
256+
'pdf',
257+
{ maxBytes: undefined, signal: undefined }
246258
)
247259
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
248260
})
@@ -307,7 +319,13 @@ describe('resolveServableDocBytes', () => {
307319
contentType: 'application/pdf',
308320
})
309321
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
310-
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(WORKSPACE_ID, source.toString('utf-8'), 'pdf')
322+
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
323+
WORKSPACE_ID,
324+
source.toString('utf-8'),
325+
'pdf',
326+
undefined,
327+
{ maxBytes: undefined, signal: undefined }
328+
)
311329
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
312330
})
313331

0 commit comments

Comments
 (0)