diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 3880e587bc..6008241147 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -8,11 +8,13 @@ import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchWorkspaceFileBuffer, + getWorkspaceFile, getWorkspaceFileByName, updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -39,7 +41,54 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { + await assertActiveWorkspaceAccess(workspaceId, userId) + switch (body.operation) { + case 'get': { + const { fileId, fileInput } = body + const selectedFileId = + fileId || + (fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput) + ? typeof fileInput.id === 'string' + ? fileInput.id + : typeof fileInput.fileId === 'string' + ? fileInput.fileId + : '' + : '') + + if (!selectedFileId) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const file = await getWorkspaceFile(workspaceId, selectedFileId) + if (!file) { + return NextResponse.json( + { success: false, error: `File not found: "${selectedFileId}"` }, + { status: 404 } + ) + } + + logger.info('File retrieved', { + fileId: file.id, + name: file.name, + }) + + return NextResponse.json({ + success: true, + data: { + file: { + id: file.id, + name: file.name, + url: ensureAbsoluteUrl(file.path), + size: file.size, + type: file.type, + key: file.key, + context: 'workspace', + }, + }, + }) + } + case 'write': { const { fileName, content, contentType } = body const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 680b96d8a1..2c922ca1d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -5,6 +5,13 @@ import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' import { usePathname, useRouter } from 'next/navigation' +import { requestJson } from '@/lib/api/client/request' +import { + addMothershipChatResourceContract, + removeMothershipChatResourceContract, + reorderMothershipChatResourcesContract, +} from '@/lib/api/contracts/mothership-tasks' +import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { toDisplayMessage } from '@/lib/copilot/chat/display-message' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' @@ -1536,6 +1543,9 @@ export function useChat( }, []) const resourcesRef = useRef(resources) resourcesRef.current = resources + const pendingPersistResourceKeysRef = useRef>(new Set()) + const inFlightResourceAddsRef = useRef>>(new Map()) + const reorderNeededAfterFlushRef = useRef(false) // Derive the effective active resource ID — auto-selects the last resource when the stored ID is // absent or no longer in the list, avoiding a separate Effect-based state correction loop. @@ -1962,6 +1972,9 @@ export function useChat( setTransportIdle() setResources([]) setActiveResourceId(null) + pendingPersistResourceKeysRef.current.clear() + inFlightResourceAddsRef.current.clear() + reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() setMessageQueue([]) clearQueueDispatchState() @@ -1974,6 +1987,44 @@ export function useChat( setTransportIdle, ]) + const flushPendingResources = useCallback(async (chatId: string) => { + const pendingKeys = pendingPersistResourceKeysRef.current + if (pendingKeys.size === 0) return + const flushPromises: Array> = [] + for (const resource of resourcesRef.current) { + if (resource.id === 'streaming-file') continue + const key = `${resource.type}:${resource.id}` + if (!pendingKeys.has(key)) continue + pendingKeys.delete(key) + const promise = requestJson(addMothershipChatResourceContract, { + body: { chatId, resource }, + }) + .catch((err) => { + pendingPersistResourceKeysRef.current.add(key) + logger.warn('Failed to flush pending resource; will retry on next hydration', err) + }) + .finally(() => { + inFlightResourceAddsRef.current.delete(key) + }) + inFlightResourceAddsRef.current.set(key, promise) + flushPromises.push(promise) + } + if (flushPromises.length === 0) return + await Promise.allSettled(flushPromises) + if (!reorderNeededAfterFlushRef.current) return + reorderNeededAfterFlushRef.current = false + const localOrder = resourcesRef.current.filter( + (r) => + r.id !== 'streaming-file' && !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) + ) + if (localOrder.length === 0) return + requestJson(reorderMothershipChatResourcesContract, { + body: { chatId, resources: localOrder }, + }).catch((err) => { + logger.warn('Failed to sync resource order after flush', err) + }) + }, []) + const adoptResolvedChatId = useCallback( (chatId: string, options?: { replaceHomeHistory?: boolean; invalidateList?: boolean }) => { const selectedChatId = selectedChatIdRef.current @@ -1992,8 +2043,9 @@ export function useChat( if (options?.invalidateList) { queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) } + flushPendingResources(chatId) }, - [queryClient, workspaceId] + [flushPendingResources, queryClient, workspaceId] ) const { data: chatHistory } = useChatHistory(resolvedChatId) @@ -2018,15 +2070,21 @@ export function useChat( } const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + const key = `${resource.type}:${resource.id}` if (persistChatId) { - // boundary-raw-fetch: fire-and-forget side-effect during stream lifecycle; intentionally avoids requestJson's response parsing/throw semantics so a failure here cannot interrupt the active turn - fetch('/api/mothership/chat/resources', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chatId: persistChatId, resource }), - }).catch((err) => { - logger.warn('Failed to persist resource', err) + const promise = requestJson(addMothershipChatResourceContract, { + body: { chatId: persistChatId, resource }, }) + .catch((err) => { + pendingPersistResourceKeysRef.current.add(key) + logger.warn('Failed to persist resource; will retry on next hydration', err) + }) + .finally(() => { + inFlightResourceAddsRef.current.delete(key) + }) + inFlightResourceAddsRef.current.set(key, promise) + } else { + pendingPersistResourceKeysRef.current.add(key) } return true }, []) @@ -2035,21 +2093,67 @@ export function useChat( setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId))) setActiveResourceId((prev) => (prev === resourceId ? null : prev)) + const key = `${resourceType}:${resourceId}` + const wasPending = pendingPersistResourceKeysRef.current.delete(key) + const inFlightAdd = inFlightResourceAddsRef.current.get(key) + if (wasPending && !inFlightAdd) return + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current - if (persistChatId) { - // boundary-raw-fetch: fire-and-forget side-effect; intentionally avoids requestJson's response parsing/throw semantics so a transient failure cannot interrupt the caller - fetch('/api/mothership/chat/resources', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chatId: persistChatId, resourceType, resourceId }), + if (!persistChatId) return + const fireDelete = () => { + requestJson(removeMothershipChatResourceContract, { + body: { chatId: persistChatId, resourceType, resourceId }, }).catch((err) => { logger.warn('Failed to persist resource removal', err) }) } + if (inFlightAdd) { + inFlightAdd.finally(fireDelete) + } else { + fireDelete() + } }, []) const reorderResources = useCallback((newOrder: MothershipResource[]) => { setResources(newOrder) + const persistChatId = chatIdRef.current ?? selectedChatIdRef.current + if (!persistChatId) return + const pendingKeys = pendingPersistResourceKeysRef.current + const inFlightAdds = inFlightResourceAddsRef.current + const hasUnsyncedAdds = newOrder.some((r) => { + const key = `${r.type}:${r.id}` + return pendingKeys.has(key) || inFlightAdds.has(key) + }) + if (hasUnsyncedAdds) { + reorderNeededAfterFlushRef.current = true + if (pendingKeys.size === 0 && inFlightAdds.size > 0) { + Promise.allSettled(Array.from(inFlightAdds.values())).then(() => { + if (!reorderNeededAfterFlushRef.current) return + reorderNeededAfterFlushRef.current = false + const chatId = chatIdRef.current ?? selectedChatIdRef.current + if (!chatId) return + const order = resourcesRef.current.filter( + (r) => + r.id !== 'streaming-file' && + !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`) + ) + if (order.length === 0) return + requestJson(reorderMothershipChatResourcesContract, { + body: { chatId, resources: order }, + }).catch((err) => { + logger.warn('Failed to sync resource order after in-flight ADDs', err) + }) + }) + } + return + } + const persistableResources = newOrder.filter((r) => r.id !== 'streaming-file') + if (persistableResources.length === 0) return + requestJson(reorderMothershipChatResourcesContract, { + body: { chatId: persistChatId, resources: persistableResources }, + }).catch((err) => { + logger.warn('Failed to persist resource reorder', err) + }) }, []) const ensureWorkflowToolResource = useCallback( @@ -2179,6 +2283,9 @@ export function useChat( setTransportIdle() setResources([]) setActiveResourceId(null) + pendingPersistResourceKeysRef.current.clear() + inFlightResourceAddsRef.current.clear() + reorderNeededAfterFlushRef.current = false resetEphemeralPreviewState() setMessageQueue([]) clearQueueDispatchState() @@ -2229,27 +2336,32 @@ export function useChat( const hasPersistedStreamingFile = chatHistory.resources.some((r) => r.id === 'streaming-file') if (hasPersistedStreamingFile) { - // boundary-raw-fetch: fire-and-forget cleanup during chat-history hydration; failures are silently swallowed to keep hydration non-blocking - fetch('/api/mothership/chat/resources', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + requestJson(removeMothershipChatResourceContract, { + body: { chatId: chatHistory.id, resourceType: 'file', resourceId: 'streaming-file', - }), + }, }).catch(() => {}) } + flushPendingResources(chatHistory.id) + const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file') - if (persistedResources.length > 0) { + const serverKeys = new Set(persistedResources.map((r) => `${r.type}:${r.id}`)) + const localOnly = resourcesRef.current.filter( + (r) => r.id !== 'streaming-file' && !serverKeys.has(`${r.type}:${r.id}`) + ) + const mergedResources = [...persistedResources, ...localOnly] + + if (mergedResources.length > 0) { const hydratedActiveResourceId = activeResourceIdRef.current && - persistedResources.some((resource) => resource.id === activeResourceIdRef.current) + mergedResources.some((resource) => resource.id === activeResourceIdRef.current) ? activeResourceIdRef.current - : persistedResources[persistedResources.length - 1].id + : mergedResources[mergedResources.length - 1].id activeResourceIdRef.current = hydratedActiveResourceId - setResources(persistedResources) + setResources(mergedResources) setActiveResourceId(hydratedActiveResourceId) for (const resource of persistedResources) { @@ -2373,6 +2485,7 @@ export function useChat( workspaceId, cancelActiveStreamReader, cancelActiveStreamRecovery, + flushPendingResources, queryClient, recoverPendingClientWorkflowTools, seedPreviewSessions, @@ -5003,9 +5116,8 @@ export function useChat( const executionId = execState.getCurrentExecutionId(workflowId) if (executionId) { execState.setCurrentExecutionId(workflowId, null) - // boundary-raw-fetch: fire-and-forget execution cancellation invoked from a stop-generation barrier; failures are silently swallowed so the stop teardown cannot stall on a contract-validation throw - fetch(`/api/workflows/${workflowId}/executions/${executionId}/cancel`, { - method: 'POST', + requestJson(cancelWorkflowExecutionContract, { + params: { id: workflowId, executionId }, }).catch(() => {}) } diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 4be2f20bbd..efefb32982 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -266,6 +266,7 @@ export const FileV3Block: BlockConfig = { type: 'dropdown' as SubBlockType, options: [ { label: 'Read', id: 'file_parser_v3' }, + { label: 'Get', id: 'file_get' }, { label: 'Write', id: 'file_write' }, { label: 'Append', id: 'file_append' }, ], @@ -294,6 +295,28 @@ export const FileV3Block: BlockConfig = { required: { field: 'operation', value: 'file_parser_v3' }, condition: { field: 'operation', value: 'file_parser_v3' }, }, + { + id: 'getFile', + title: 'File', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'getFileInput', + acceptedTypes: '*', + placeholder: 'Select a workspace file', + multiple: false, + mode: 'basic', + condition: { field: 'operation', value: 'file_get' }, + required: { field: 'operation', value: 'file_get' }, + }, + { + id: 'getFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'getFileInput', + placeholder: 'Workspace file ID', + mode: 'advanced', + condition: { field: 'operation', value: 'file_get' }, + required: { field: 'operation', value: 'file_get' }, + }, { id: 'fileName', title: 'File Name', @@ -349,7 +372,7 @@ export const FileV3Block: BlockConfig = { }, ], tools: { - access: ['file_parser_v3', 'file_write', 'file_append'], + access: ['file_parser_v3', 'file_get', 'file_write', 'file_append'], config: { tool: (params) => params.operation || 'file_parser_v3', params: (params) => { @@ -390,6 +413,25 @@ export const FileV3Block: BlockConfig = { } } + if (operation === 'file_get') { + const getInput = params.getFileInput + if (!getInput) { + throw new Error('File is required for get') + } + + if (typeof getInput === 'string') { + return { + fileId: getInput.trim(), + workspaceId: params._context?.workspaceId, + } + } + + return { + fileInput: normalizeFileInput(getInput, { single: true }), + workspaceId: params._context?.workspaceId, + } + } + const fileInput = params.fileInput if (!fileInput) { logger.error('No file input provided') @@ -428,9 +470,13 @@ export const FileV3Block: BlockConfig = { }, }, inputs: { - operation: { type: 'string', description: 'Operation to perform (read, write, or append)' }, + operation: { + type: 'string', + description: 'Operation to perform (read, get, write, or append)', + }, fileInput: { type: 'json', description: 'File input for read' }, fileType: { type: 'string', description: 'File type for read' }, + getFileInput: { type: 'json', description: 'Selected file or workspace file ID for get' }, fileName: { type: 'string', description: 'Name for a new file (write)' }, content: { type: 'string', description: 'File content to write' }, contentType: { type: 'string', description: 'MIME content type for write' }, @@ -446,6 +492,10 @@ export const FileV3Block: BlockConfig = { type: 'string', description: 'All file contents merged into a single text string (read)', }, + file: { + type: 'file', + description: 'Workspace file object (get)', + }, id: { type: 'string', description: 'File ID (write)', diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 8987301ad4..7120d38d15 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -22,9 +22,21 @@ export const fileManageAppendBodySchema = z.object({ content: z.string({ error: 'content is required for append operation' }), }) -export const fileManageBodySchema = z.discriminatedUnion('operation', [ +export const fileManageGetBodySchema = z + .object({ + operation: z.literal('get'), + workspaceId: z.string().min(1).optional(), + fileId: z.string().min(1).optional(), + fileInput: z.any().optional(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for get operation', + }) + +export const fileManageBodySchema = z.union([ fileManageWriteBodySchema, fileManageAppendBodySchema, + fileManageGetBodySchema, ]) export const fileManageContract = defineRouteContract({ diff --git a/apps/sim/tools/file/get.ts b/apps/sim/tools/file/get.ts new file mode 100644 index 0000000000..6f05dbca7d --- /dev/null +++ b/apps/sim/tools/file/get.ts @@ -0,0 +1,54 @@ +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileGetParams { + fileId?: string + fileInput?: unknown + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileGetTool: ToolConfig = { + id: 'file_get', + name: 'File Get', + description: 'Get a workspace file object from a selected file or canonical workspace file ID.', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canonical workspace file ID.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected workspace file object.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'get', + fileId: params.fileId, + fileInput: params.fileInput, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to get file' } + } + return { success: true, output: data.data } + }, + + outputs: { + file: { type: 'file', description: 'Workspace file object' }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 2a60ea594f..4e0b6daed0 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -1,6 +1,7 @@ import { fileParserTool, fileParserV2Tool, fileParserV3Tool } from '@/tools/file/parser' export { fileAppendTool } from '@/tools/file/append' +export { fileGetTool } from '@/tools/file/get' export { fileWriteTool } from '@/tools/file/write' export const fileParseTool = fileParserTool diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4fbaff816b..014ca723df 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -643,6 +643,7 @@ import { } from '@/tools/fathom' import { fileAppendTool, + fileGetTool, fileParserV2Tool, fileParserV3Tool, fileParseTool, @@ -3213,6 +3214,7 @@ export const tools: Record = { file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, file_append: fileAppendTool, + file_get: fileGetTool, file_write: fileWriteTool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool,