import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, asc, desc, eq, isNull, or } from 'drizzle-orm' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import { type GrepCountEntry, type GrepMatch, type GrepOptions, grepReadResult, WorkspaceFileGrepError, } from '@/lib/copilot/vfs/operations' import { decodeVfsSegment, encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import { getServePathPrefix } from '@/lib/uploads' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('UploadFileReader') /** * Canonical comparison key for an upload's VFS name. Accepts both the raw display * name and a percent-encoded segment (decode first — a no-op for raw names — * then re-encode to the canonical `files/`-style form) so either spelling * resolves the same row. Raw names containing a literal `%` cannot be decoded; * fall back to encoding the raw name. */ function canonicalUploadKey(name: string): string { let decoded = name try { decoded = decodeVfsSegment(name) } catch { decoded = name } try { return encodeVfsSegment(decoded) } catch { return name.trim() } } /** VFS-visible name. Coalesces to originalName for legacy rows that predate displayName. */ function vfsName(row: typeof workspaceFiles.$inferSelect): string { return row.displayName ?? row.originalName } function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): WorkspaceFileRecord { const pathPrefix = getServePathPrefix() return { id: row.id, workspaceId: row.workspaceId || '', name: vfsName(row), key: row.key, path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`, size: row.size, type: row.contentType, uploadedBy: row.userId, deletedAt: row.deletedAt, uploadedAt: row.uploadedAt, updatedAt: row.updatedAt, storageContext: 'mothership', } } /** * Resolve a mothership upload row by VFS name (the collision-disambiguated `displayName` * for new rows, or `originalName` for legacy rows that predate the column). Prefers an * exact DB match; falls back to a normalized scan when the model passes a visually * equivalent name (e.g. macOS U+202F vs ASCII space in screenshot filenames). * * On ambiguity (multiple legacy rows sharing the same originalName in one chat — the * pre-displayName collision case), returns the most recent upload. New rows are unique * by index so this only affects pre-fix data. */ export async function findMothershipUploadRowByChatAndName( chatId: string, fileName: string ): Promise { const exactRows = await db .select() .from(workspaceFiles) .where( and( eq(workspaceFiles.chatId, chatId), eq(workspaceFiles.context, 'mothership'), or( eq(workspaceFiles.displayName, fileName), and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, fileName)) ), isNull(workspaceFiles.deletedAt) ) ) .orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id)) .limit(1) if (exactRows[0]) { return exactRows[0] } const allRows = await db .select() .from(workspaceFiles) .where( and( eq(workspaceFiles.chatId, chatId), eq(workspaceFiles.context, 'mothership'), isNull(workspaceFiles.deletedAt) ) ) .orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id)) const segmentKey = canonicalUploadKey(fileName) return allRows.find((r) => canonicalUploadKey(vfsName(r)) === segmentKey) ?? null } /** * List all chat-scoped uploads for a given chat in upload order. */ export async function listChatUploads(chatId: string): Promise { try { const rows = await db .select() .from(workspaceFiles) .where( and( eq(workspaceFiles.chatId, chatId), eq(workspaceFiles.context, 'mothership'), isNull(workspaceFiles.deletedAt) ) ) .orderBy(asc(workspaceFiles.uploadedAt), asc(workspaceFiles.id)) return rows.map(toWorkspaceFileRecord) } catch (err) { logger.warn('Failed to list chat uploads', { chatId, error: toError(err).message, }) return [] } } /** * Read a specific uploaded file by display name within a chat session. * Resolves names with `normalizeVfsSegment` so macOS screenshot spacing (e.g. U+202F) * matches when the model passes a visually equivalent path. */ export async function readChatUpload( filename: string, chatId: string ): Promise { try { const row = await findMothershipUploadRowByChatAndName(chatId, filename) if (!row) return null return readFileRecord(toWorkspaceFileRecord(row)) } catch (err) { logger.warn('Failed to read chat upload', { filename, chatId, error: toError(err).message, }) return null } } /** * Grep the content of a single chat upload (`uploads/`), mirroring * {@link WorkspaceVFS.grepFile} for the chat-scoped uploads namespace. Resolves * the upload by name (raw or percent-encoded), reads its text per file type, and * greps it. Throws {@link WorkspaceFileGrepError} when the upload is missing or * has no searchable text (image/binary/too-large) so the caller surfaces the * message verbatim. */ export async function grepChatUpload( filename: string, chatId: string, pattern: string, options?: GrepOptions ): Promise { const row = await findMothershipUploadRowByChatAndName(chatId, filename) if (!row) { throw new WorkspaceFileGrepError( `Upload not found: "${filename}". Use glob("uploads/*") to list available uploads.` ) } const record = toWorkspaceFileRecord(row) const result = await readFileRecord(record) if (!result) { throw new WorkspaceFileGrepError(`Upload content not found for "${filename}".`) } const uploadsPath = `uploads/${canonicalUploadKey(record.name)}` return grepReadResult(uploadsPath, result, pattern, uploadsPath, options) }