Skip to content

Commit b200756

Browse files
committed
feat(files): add workspace content search
1 parent 553849a commit b200756

40 files changed

Lines changed: 23342 additions & 24 deletions

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: File
3-
description: Read, get content, fetch, write, append, compress, decompress, and manage sharing for files
3+
description: Read, search, get content, fetch, write, append, compress, decompress, and manage sharing for files
44
---
55

66
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -11,23 +11,24 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1111
/>
1212

1313
{/* MANUAL-CONTENT-START:intro */}
14-
The File block is a built-in Sim block for working with files stored in the workspace, or fetched from external URLs. It handles reading, writing, appending, compressing, decompressing, and sharing files as part of a workflow.
14+
The File block is a built-in Sim block for working with files stored in the workspace, or fetched from external URLs. It handles reading, searching, writing, appending, compressing, decompressing, and sharing files as part of a workflow.
1515

1616
With the File block, you can:
1717

1818
- **Read and extract content**: Load workspace file objects and extract their text content
19+
- **Search workspace content**: Find literal text across indexed active workspace files with bounded line-level results
1920
- **Fetch from URLs**: Retrieve and parse files from external URLs with custom headers
2021
- **Write and append**: Create new workspace files or append content to existing ones
2122
- **Compress and decompress**: Bundle files into a .zip archive or extract an archive into the workspace
2223
- **Manage sharing**: Enable or disable a public share link for a file, with public, password, email, or SSO access modes
2324

24-
In Sim, the File block allows your agents to read and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. This makes it possible to move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link.
25+
In Sim, the File block allows your agents to search, read, and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. This makes it possible to explore workspace content, move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link.
2526
{/* MANUAL-CONTENT-END */}
2627

2728

2829
## Usage Instructions
2930

30-
Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file.
31+
Read workspace file objects, search indexed text across all active workspace files, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file.
3132

3233

3334

@@ -67,6 +68,35 @@ Extract the text content of one or more workspace files from selected file objec
6768
| --------- | ---- | ----------- |
6869
| `contents` | array | Array of file text contents, one entry per file in input order |
6970

71+
### File Search
72+
73+
Search indexed text across active workspace files using literal smart-case substring matching.
74+
75+
#### Input
76+
77+
| Parameter | Type | Required | Description |
78+
| --------- | ---- | -------- | ----------- |
79+
| `query` | string | Yes | Literal text to find \(3-512 characters\). Uppercase Unicode letters make matching case-sensitive. |
80+
| `maxResults` | number | No | Hard result cap configured by the workflow builder \(1-200, default 50\). |
81+
82+
#### Output
83+
84+
| Parameter | Type | Description |
85+
| --------- | ---- | ----------- |
86+
| `results` | array | Matching logical lines with their workspace file ID and 1-based line number. |
87+
|`fileId` | string | Canonical workspace file ID. |
88+
|`lineNumber` | number | 1-based logical line number. |
89+
|`text` | string | Matching line or bounded match-centered preview. |
90+
| `count` | number | Number of returned matching lines. |
91+
| `truncated` | boolean | Whether more matching lines exist beyond the configured hard cap. |
92+
| `complete` | boolean | Whether every current file revision is indexed without failures. |
93+
| `indexStatus` | object | Current workspace search-index coverage by file status. |
94+
|`readyFiles` | number | Files whose current revision is searchable. |
95+
|`pendingFiles` | number | Files still waiting to be indexed. |
96+
|`failedFiles` | number | Files whose current indexing attempt failed. |
97+
|`skippedFiles` | number | Files intentionally excluded because they are unsupported or oversized. |
98+
|`partialFiles` | number | Searchable files whose extracted text was truncated by the parser or cap. |
99+
70100
### File Fetch
71101

72102
Fetch and parse a file from a URL with optional custom headers.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
enqueueDispatch: vi.fn(),
9+
verifyCronAuth: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mocks.verifyCronAuth }))
13+
vi.mock('@/lib/workspace-files/search/enqueue-dispatch', () => ({
14+
enqueueWorkspaceFileSearchDispatch: mocks.enqueueDispatch,
15+
}))
16+
17+
import { GET } from '@/app/api/cron/workspace-file-search-dispatch/route'
18+
19+
function request() {
20+
return createMockRequest(
21+
'GET',
22+
undefined,
23+
{},
24+
'http://localhost:3000/api/cron/workspace-file-search-dispatch'
25+
)
26+
}
27+
28+
describe('workspace file search dispatch route', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks()
31+
mocks.verifyCronAuth.mockReturnValue(null)
32+
})
33+
34+
it('returns as soon as Trigger.dev accepts the dispatcher run', async () => {
35+
mocks.enqueueDispatch.mockResolvedValue({ backend: 'trigger-dev', jobId: 'run-1' })
36+
37+
const response = await GET(request())
38+
39+
expect(response.status).toBe(202)
40+
await expect(response.json()).resolves.toEqual({
41+
success: true,
42+
triggered: true,
43+
backend: 'trigger-dev',
44+
jobId: 'run-1',
45+
})
46+
})
47+
48+
it('returns the cron auth refusal without dispatching', async () => {
49+
mocks.verifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))
50+
51+
const response = await GET(request())
52+
53+
expect(response.status).toBe(401)
54+
expect(mocks.enqueueDispatch).not.toHaveBeenCalled()
55+
})
56+
57+
it('fails closed when Trigger.dev does not accept the dispatcher run', async () => {
58+
mocks.enqueueDispatch.mockRejectedValue(new Error('trigger unavailable'))
59+
60+
const response = await GET(request())
61+
62+
expect(response.status).toBe(500)
63+
await expect(response.json()).resolves.toEqual({
64+
success: false,
65+
error: 'Dispatcher enqueue failed',
66+
})
67+
})
68+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { enqueueWorkspaceFileSearchDispatch } from '@/lib/workspace-files/search/enqueue-dispatch'
7+
8+
const logger = createLogger('WorkspaceFileSearchDispatchRoute')
9+
10+
export const dynamic = 'force-dynamic'
11+
export const maxDuration = 60
12+
13+
export const GET = withRouteHandler(async (request: NextRequest) => {
14+
const authError = verifyCronAuth(request, 'Workspace file search dispatcher')
15+
if (authError) return authError
16+
17+
try {
18+
const result = await enqueueWorkspaceFileSearchDispatch()
19+
logger.info('Workspace file search dispatcher accepted', result)
20+
return NextResponse.json({ success: true, triggered: true, ...result }, { status: 202 })
21+
} catch (error) {
22+
logger.error('Workspace file search dispatcher enqueue failed', {
23+
error: toError(error).message,
24+
})
25+
return NextResponse.json(
26+
{ success: false, error: 'Dispatcher enqueue failed' },
27+
{ status: 500 }
28+
)
29+
}
30+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
dispatch: vi.fn(),
8+
task: vi.fn((config: unknown) => config),
9+
}))
10+
11+
vi.mock('@trigger.dev/sdk', () => ({ task: mocks.task }))
12+
vi.mock('@/lib/workspace-files/search/dispatcher', () => ({
13+
dispatchWorkspaceFileSearchIndexJobs: mocks.dispatch,
14+
}))
15+
16+
import { FILE_SEARCH_DISPATCH_MAX_DURATION_SECONDS } from '@/lib/workspace-files/search/constants'
17+
import { workspaceFileSearchDispatchTask } from '@/background/workspace-file-search-dispatch'
18+
19+
describe('workspace file search dispatch task', () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks()
22+
})
23+
24+
it('serializes bounded dispatcher runs outside the cron request', async () => {
25+
expect(workspaceFileSearchDispatchTask).toMatchObject({
26+
id: 'workspace-file-search-dispatch',
27+
machine: 'small-1x',
28+
maxDuration: FILE_SEARCH_DISPATCH_MAX_DURATION_SECONDS,
29+
retry: { maxAttempts: 3 },
30+
queue: {
31+
name: 'workspace-file-search-dispatch',
32+
concurrencyLimit: 1,
33+
},
34+
})
35+
36+
mocks.dispatch.mockResolvedValue({
37+
dispatchedFiles: 2,
38+
backfilledFiles: 1000,
39+
reapedClaims: 0,
40+
lockAcquired: true,
41+
})
42+
await workspaceFileSearchDispatchTask.run()
43+
expect(mocks.dispatch).toHaveBeenCalledOnce()
44+
})
45+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { task } from '@trigger.dev/sdk'
2+
import { FILE_SEARCH_DISPATCH_MAX_DURATION_SECONDS } from '@/lib/workspace-files/search/constants'
3+
import { dispatchWorkspaceFileSearchIndexJobs } from '@/lib/workspace-files/search/dispatcher'
4+
5+
/**
6+
* Runs the bounded search-index control plane outside the cron request. Per-file parsing remains
7+
* isolated in `workspace-file-search-index`; this task only backfills, claims, and enqueues work.
8+
*/
9+
export const workspaceFileSearchDispatchTask = task({
10+
id: 'workspace-file-search-dispatch',
11+
machine: 'small-1x',
12+
maxDuration: FILE_SEARCH_DISPATCH_MAX_DURATION_SECONDS,
13+
retry: { maxAttempts: 3 },
14+
queue: {
15+
name: 'workspace-file-search-dispatch',
16+
concurrencyLimit: 1,
17+
},
18+
run: () => dispatchWorkspaceFileSearchIndexJobs(),
19+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
indexWorkspaceFile: vi.fn(),
8+
markFailed: vi.fn(),
9+
task: vi.fn((config: unknown) => config),
10+
}))
11+
12+
vi.mock('@trigger.dev/sdk', () => ({ task: mocks.task }))
13+
vi.mock('@/lib/workspace-files/search/indexing', () => ({
14+
indexWorkspaceFileForSearch: mocks.indexWorkspaceFile,
15+
markWorkspaceFileSearchIndexFailed: mocks.markFailed,
16+
}))
17+
18+
import {
19+
FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
20+
FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
21+
} from '@/lib/workspace-files/search/constants'
22+
import { workspaceFileSearchIndexTask } from '@/background/workspace-file-search-index'
23+
24+
const payload = {
25+
workspaceId: 'workspace-1',
26+
fileId: 'file-1',
27+
sourceContentUpdatedAt: '2026-08-29T12:00:00.000Z',
28+
}
29+
30+
describe('workspace file search index task', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
})
34+
35+
it('uses isolated medium workers with a hard global concurrency and duration cap', () => {
36+
expect(workspaceFileSearchIndexTask).toMatchObject({
37+
id: 'workspace-file-search-index',
38+
machine: 'medium-1x',
39+
maxDuration: FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
40+
retry: { maxAttempts: 3 },
41+
queue: {
42+
name: 'workspace-file-search-index',
43+
concurrencyLimit: FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
44+
},
45+
})
46+
})
47+
48+
it('passes the Trigger.dev abort signal to the single-revision indexer', async () => {
49+
const signal = new AbortController().signal
50+
mocks.indexWorkspaceFile.mockResolvedValue(undefined)
51+
52+
await workspaceFileSearchIndexTask.run(payload, { signal })
53+
54+
expect(mocks.indexWorkspaceFile).toHaveBeenCalledWith(payload, signal)
55+
})
56+
57+
it('marks the revision failed only from the terminal onFailure hook', async () => {
58+
mocks.indexWorkspaceFile.mockRejectedValue(new Error('retryable parser failure'))
59+
60+
await expect(
61+
workspaceFileSearchIndexTask.run(payload, { signal: new AbortController().signal })
62+
).rejects.toThrow('retryable parser failure')
63+
expect(mocks.markFailed).not.toHaveBeenCalled()
64+
65+
await workspaceFileSearchIndexTask.onFailure({ payload })
66+
expect(mocks.markFailed).toHaveBeenCalledWith(payload)
67+
})
68+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { task } from '@trigger.dev/sdk'
2+
import {
3+
FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
4+
FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
5+
} from '@/lib/workspace-files/search/constants'
6+
import {
7+
indexWorkspaceFileForSearch,
8+
markWorkspaceFileSearchIndexFailed,
9+
type WorkspaceFileSearchIndexPayload,
10+
} from '@/lib/workspace-files/search/indexing'
11+
12+
/**
13+
* Builds one immutable workspace-file search revision. PostgreSQL owns the durable state; this
14+
* task only supplies isolated compute, retries, and a hard global execution cap.
15+
*/
16+
export const workspaceFileSearchIndexTask = task({
17+
id: 'workspace-file-search-index',
18+
machine: 'medium-1x',
19+
maxDuration: FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
20+
retry: { maxAttempts: 3 },
21+
queue: {
22+
name: 'workspace-file-search-index',
23+
concurrencyLimit: FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
24+
},
25+
run: (payload: WorkspaceFileSearchIndexPayload, { signal }) =>
26+
indexWorkspaceFileForSearch(payload, signal),
27+
onFailure: async ({ payload }) => {
28+
await markWorkspaceFileSearchIndexFailed(payload)
29+
},
30+
})

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,31 @@ describe('FileV5Block', () => {
6262
expect(FileV5Block.tools.config.tool({ operation: 'file_fetch' })).toBe('file_fetch')
6363
expect(FileV5Block.tools.config.tool({ operation: 'file_write' })).toBe('file_write')
6464
expect(FileV5Block.tools.config.tool({ operation: 'file_append' })).toBe('file_append')
65+
expect(FileV5Block.tools.config.tool({ operation: 'file_search' })).toBe('file_search')
66+
})
67+
68+
it('keeps the builder-configured search limit as a fixed hard cap', () => {
69+
expect(
70+
buildParams({
71+
operation: 'file_search',
72+
query: '',
73+
maxResults: '25',
74+
})
75+
).toEqual({ query: '', maxResults: 25 })
76+
77+
const query = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'query')
78+
const maxResults = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'maxResults')
79+
expect(query?.paramVisibility).toBe('user-or-llm')
80+
expect(maxResults?.paramVisibility).toBe('user-only')
81+
expect(query?.canonicalParamId).toBeUndefined()
82+
expect(maxResults?.canonicalParamId).toBeUndefined()
83+
expect(maxResults?.value?.()).toBe('50')
6584
})
6685

6786
it('read returns only the files output (no redundant file)', () => {
6887
expect(FileV5Block.outputs.files).toBeDefined()
6988
expect(FileV5Block.outputs.contents).toBeDefined()
89+
expect(FileV5Block.outputs.results).toBeDefined()
7090
expect(FileV5Block.outputs.file).toBeUndefined()
7191
})
7292

0 commit comments

Comments
 (0)