Skip to content

Commit 30b7192

Browse files
improvement(vfs): update custom glob impl to use micromatch, fix vfs filename regex (#3680)
* improvement(vfs): update custom glob impl to use micromatch, fix vfs filename regex * add tests * file caps * address comments * fix open resource * consolidate files
1 parent 17bdc80 commit 30b7192

File tree

12 files changed

+319
-120
lines changed

12 files changed

+319
-120
lines changed

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
260260
}, [file])
261261

262262
const handleOpenInFiles = useCallback(() => {
263-
router.push(`/workspace/${workspaceId}/files?fileId=${fileId}`)
263+
router.push(`/workspace/${workspaceId}/files?fileId=${encodeURIComponent(fileId)}`)
264264
}, [router, workspaceId, fileId])
265265

266266
return (

apps/sim/lib/copilot/orchestrator/tool-executor/index.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { validateMcpDomain } from '@/lib/mcp/domain-check'
1616
import { mcpService } from '@/lib/mcp/service'
1717
import { generateMcpServerId } from '@/lib/mcp/utils'
1818
import { getAllOAuthServices } from '@/lib/oauth/utils'
19+
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1920
import {
2021
deleteCustomTool,
2122
getCustomToolById,
@@ -24,7 +25,7 @@ import {
2425
} from '@/lib/workflows/custom-tools/operations'
2526
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
2627
import { getWorkflowById } from '@/lib/workflows/utils'
27-
import { isMcpTool } from '@/executor/constants'
28+
import { isMcpTool, isUuid } from '@/executor/constants'
2829
import { executeTool } from '@/tools'
2930
import { getTool, resolveToolId } from '@/tools/utils'
3031
import {
@@ -1029,15 +1030,42 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
10291030
list: (p, c) => executeVfsList(p, c),
10301031

10311032
// Resource visibility
1032-
open_resource: async (p: OpenResourceParams) => {
1033+
open_resource: async (p: OpenResourceParams, c: ExecutionContext) => {
10331034
const validated = validateOpenResourceParams(p)
10341035
if (!validated.success) {
10351036
return { success: false, error: validated.error }
10361037
}
10371038

10381039
const params = validated.params
10391040
const resourceType = params.type
1040-
const resourceId = params.id
1041+
let resourceId = params.id
1042+
let title: string = resourceType
1043+
1044+
if (resourceType === 'file') {
1045+
if (!c.workspaceId) {
1046+
return {
1047+
success: false,
1048+
error:
1049+
'Opening a workspace file requires workspace context. Pass the file UUID from files/<name>/meta.json.',
1050+
}
1051+
}
1052+
if (!isUuid(params.id)) {
1053+
return {
1054+
success: false,
1055+
error:
1056+
'open_resource for files requires the canonical UUID from files/<name>/meta.json (the "id" field). Do not pass VFS paths, display names, or file_<name> strings.',
1057+
}
1058+
}
1059+
const record = await getWorkspaceFile(c.workspaceId, params.id)
1060+
if (!record) {
1061+
return {
1062+
success: false,
1063+
error: `No workspace file with id "${params.id}". Confirm the UUID from meta.json.`,
1064+
}
1065+
}
1066+
resourceId = record.id
1067+
title = record.name
1068+
}
10411069

10421070
return {
10431071
success: true,
@@ -1046,7 +1074,7 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
10461074
{
10471075
type: resourceType as 'workflow' | 'table' | 'knowledgebase' | 'file',
10481076
id: resourceId,
1049-
title: resourceType,
1077+
title,
10501078
},
10511079
],
10521080
}

apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { workflow, workspaceFiles } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, isNull } from 'drizzle-orm'
5+
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/orchestrator/tool-executor/upload-file-reader'
56
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
67
import { getServePathPrefix } from '@/lib/uploads'
78
import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -12,22 +13,6 @@ import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'
1213

1314
const logger = createLogger('MaterializeFile')
1415

15-
async function findUploadRecord(fileName: string, chatId: string) {
16-
const rows = await db
17-
.select()
18-
.from(workspaceFiles)
19-
.where(
20-
and(
21-
eq(workspaceFiles.originalName, fileName),
22-
eq(workspaceFiles.chatId, chatId),
23-
eq(workspaceFiles.context, 'mothership'),
24-
isNull(workspaceFiles.deletedAt)
25-
)
26-
)
27-
.limit(1)
28-
return rows[0] ?? null
29-
}
30-
3116
function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
3217
const pathPrefix = getServePathPrefix()
3318
return {
@@ -41,21 +26,23 @@ function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
4126
uploadedBy: row.userId,
4227
deletedAt: row.deletedAt,
4328
uploadedAt: row.uploadedAt,
29+
storageContext: 'mothership' as const,
4430
}
4531
}
4632

4733
async function executeSave(fileName: string, chatId: string): Promise<ToolCallResult> {
34+
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
35+
if (!row) {
36+
return {
37+
success: false,
38+
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
39+
}
40+
}
41+
4842
const [updated] = await db
4943
.update(workspaceFiles)
5044
.set({ context: 'workspace', chatId: null })
51-
.where(
52-
and(
53-
eq(workspaceFiles.originalName, fileName),
54-
eq(workspaceFiles.chatId, chatId),
55-
eq(workspaceFiles.context, 'mothership'),
56-
isNull(workspaceFiles.deletedAt)
57-
)
58-
)
45+
.where(and(eq(workspaceFiles.id, row.id), isNull(workspaceFiles.deletedAt)))
5946
.returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName })
6047

6148
if (!updated) {
@@ -84,7 +71,7 @@ async function executeImport(
8471
workspaceId: string,
8572
userId: string
8673
): Promise<ToolCallResult> {
87-
const row = await findUploadRecord(fileName, chatId)
74+
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
8875
if (!row) {
8976
return {
9077
success: false,

apps/sim/lib/copilot/orchestrator/tool-executor/upload-file-reader.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { workspaceFiles } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, isNull } from 'drizzle-orm'
55
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
6+
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
67
import { getServePathPrefix } from '@/lib/uploads'
78
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
89

@@ -21,9 +22,50 @@ function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): Workspa
2122
uploadedBy: row.userId,
2223
deletedAt: row.deletedAt,
2324
uploadedAt: row.uploadedAt,
25+
storageContext: 'mothership',
2426
}
2527
}
2628

29+
/**
30+
* Resolve a mothership upload row by `originalName`, preferring an exact DB match (limit 1) and
31+
* only scanning all chat uploads when that misses (e.g. macOS U+202F vs ASCII space in the name).
32+
*/
33+
export async function findMothershipUploadRowByChatAndName(
34+
chatId: string,
35+
fileName: string
36+
): Promise<typeof workspaceFiles.$inferSelect | null> {
37+
const exactRows = await db
38+
.select()
39+
.from(workspaceFiles)
40+
.where(
41+
and(
42+
eq(workspaceFiles.chatId, chatId),
43+
eq(workspaceFiles.context, 'mothership'),
44+
eq(workspaceFiles.originalName, fileName),
45+
isNull(workspaceFiles.deletedAt)
46+
)
47+
)
48+
.limit(1)
49+
50+
if (exactRows[0]) {
51+
return exactRows[0]
52+
}
53+
54+
const allRows = await db
55+
.select()
56+
.from(workspaceFiles)
57+
.where(
58+
and(
59+
eq(workspaceFiles.chatId, chatId),
60+
eq(workspaceFiles.context, 'mothership'),
61+
isNull(workspaceFiles.deletedAt)
62+
)
63+
)
64+
65+
const segmentKey = normalizeVfsSegment(fileName)
66+
return allRows.find((r) => normalizeVfsSegment(r.originalName) === segmentKey) ?? null
67+
}
68+
2769
/**
2870
* List all chat-scoped uploads for a given chat.
2971
*/
@@ -51,30 +93,18 @@ export async function listChatUploads(chatId: string): Promise<WorkspaceFileReco
5193
}
5294

5395
/**
54-
* Read a specific uploaded file by name within a chat session.
96+
* Read a specific uploaded file by display name within a chat session.
97+
* Resolves names with `normalizeVfsSegment` so macOS screenshot spacing (e.g. U+202F)
98+
* matches when the model passes a visually equivalent path.
5599
*/
56100
export async function readChatUpload(
57101
filename: string,
58102
chatId: string
59103
): Promise<FileReadResult | null> {
60104
try {
61-
const rows = await db
62-
.select()
63-
.from(workspaceFiles)
64-
.where(
65-
and(
66-
eq(workspaceFiles.chatId, chatId),
67-
eq(workspaceFiles.context, 'mothership'),
68-
eq(workspaceFiles.originalName, filename),
69-
isNull(workspaceFiles.deletedAt)
70-
)
71-
)
72-
.limit(1)
73-
74-
if (rows.length === 0) return null
75-
76-
const record = toWorkspaceFileRecord(rows[0])
77-
return readFileRecord(record)
105+
const row = await findMothershipUploadRowByChatAndName(chatId, filename)
106+
if (!row) return null
107+
return readFileRecord(toWorkspaceFileRecord(row))
78108
} catch (err) {
79109
logger.warn('Failed to read chat upload', {
80110
filename,

apps/sim/lib/copilot/vfs/file-reader.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { isImageFileType } from '@/lib/uploads/utils/file-utils'
55

66
const logger = createLogger('FileReader')
77

8-
const MAX_TEXT_READ_BYTES = 512 * 1024 // 512 KB
9-
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
8+
const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
9+
const MAX_IMAGE_READ_BYTES = 20 * 1024 * 1024 // 20 MB
1010

1111
const TEXT_TYPES = new Set([
1212
'text/plain',
@@ -53,7 +53,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
5353
if (isImageFileType(record.type)) {
5454
if (record.size > MAX_IMAGE_READ_BYTES) {
5555
return {
56-
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB)]`,
56+
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 20MB)]`,
5757
totalLines: 1,
5858
}
5959
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Normalize a string for use as a single VFS path segment (workflow name, file name, etc.).
3+
* Applies NFC normalization, trims, strips ASCII control characters, maps `/` to `-`, and
4+
* collapses Unicode whitespace (including U+202F as in macOS screenshot names) to a single
5+
* ASCII space.
6+
*/
7+
export function normalizeVfsSegment(name: string): string {
8+
return name
9+
.normalize('NFC')
10+
.trim()
11+
.replace(/[\x00-\x1f\x7f]/g, '')
12+
.replace(/\//g, '-')
13+
.replace(/\s+/g, ' ')
14+
}

0 commit comments

Comments
 (0)