diff --git a/apps/sim/app/_shell/providers/get-query-client.ts b/apps/sim/app/_shell/providers/get-query-client.ts index 8d7d2ecbb79..41093221d1c 100644 --- a/apps/sim/app/_shell/providers/get-query-client.ts +++ b/apps/sim/app/_shell/providers/get-query-client.ts @@ -11,7 +11,7 @@ function makeQueryClient() { retryOnMount: false, }, mutations: { - retry: 1, + retry: false, }, dehydrate: { shouldDehydrateQuery: (query) => diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index ce16da5dd28..69d7bb204dc 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -54,6 +54,11 @@ export async function POST(req: NextRequest) { const body = await req.json() const { chatId, resource } = AddResourceSchema.parse(body) + // Ephemeral UI tab (client does not POST this; guard for old clients / bugs). + if (resource.id === 'streaming-file') { + return NextResponse.json({ success: true }) + } + if (!VALID_RESOURCE_TYPES.has(resource.type)) { return createBadRequestResponse(`Invalid resource type: ${resource.type}`) } diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 305280b8f0c..d02f5e2bdc3 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -15,6 +15,7 @@ import { import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer' +import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -31,6 +32,8 @@ import { getUserEntityPermissions, } from '@/lib/workspaces/permissions/utils' +export const maxDuration = 3600 + const logger = createLogger('CopilotChatAPI') const FileAttachmentSchema = z.object({ @@ -48,7 +51,7 @@ const ChatMessageSchema = z.object({ workflowId: z.string().optional(), workspaceId: z.string().optional(), workflowName: z.string().optional(), - model: z.string().optional().default('claude-opus-4-5'), + model: z.string().optional().default('claude-opus-4-6'), mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), prefetch: z.boolean().optional(), createNewChat: z.boolean().optional().default(false), @@ -180,6 +183,29 @@ export async function POST(req: NextRequest) { }) } catch {} + let currentChat: any = null + let conversationHistory: any[] = [] + let actualChatId = chatId + const selectedModel = model || 'claude-opus-4-6' + + if (chatId || createNewChat) { + const chatResult = await resolveOrCreateChat({ + chatId, + userId: authenticatedUserId, + workflowId, + model: selectedModel, + }) + currentChat = chatResult.chat + actualChatId = chatResult.chatId || chatId + conversationHistory = Array.isArray(chatResult.conversationHistory) + ? chatResult.conversationHistory + : [] + + if (chatId && !currentChat) { + return createBadRequestResponse('Chat not found') + } + } + let agentContexts: Array<{ type: string; content: string }> = [] if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) { try { @@ -188,7 +214,8 @@ export async function POST(req: NextRequest) { normalizedContexts as any, authenticatedUserId, message, - resolvedWorkspaceId + resolvedWorkspaceId, + actualChatId ) agentContexts = processed logger.info(`[${tracker.requestId}] Contexts processed for request`, { @@ -210,29 +237,6 @@ export async function POST(req: NextRequest) { } } - let currentChat: any = null - let conversationHistory: any[] = [] - let actualChatId = chatId - const selectedModel = model || 'claude-opus-4-5' - - if (chatId || createNewChat) { - const chatResult = await resolveOrCreateChat({ - chatId, - userId: authenticatedUserId, - workflowId, - model: selectedModel, - }) - currentChat = chatResult.chat - actualChatId = chatResult.chatId || chatId - conversationHistory = Array.isArray(chatResult.conversationHistory) - ? chatResult.conversationHistory - : [] - - if (chatId && !currentChat) { - return createBadRequestResponse('Chat not found') - } - } - const effectiveMode = mode === 'agent' ? 'build' : mode const userPermission = resolvedWorkspaceId @@ -283,11 +287,44 @@ export async function POST(req: NextRequest) { }) } catch {} + if (actualChatId) { + const userMsg = { + id: userMessageIdToUse, + role: 'user' as const, + content: message, + timestamp: new Date().toISOString(), + ...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }), + ...(Array.isArray(normalizedContexts) && + normalizedContexts.length > 0 && { + contexts: normalizedContexts, + }), + } + + const [updated] = await db + .update(copilotChats) + .set({ + messages: sql`${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb`, + conversationId: userMessageIdToUse, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, actualChatId)) + .returning({ messages: copilotChats.messages }) + + if (updated) { + const freshMessages: any[] = Array.isArray(updated.messages) ? updated.messages : [] + conversationHistory = freshMessages.filter((m: any) => m.id !== userMessageIdToUse) + } + } + if (stream) { + const executionId = crypto.randomUUID() + const runId = crypto.randomUUID() const sseStream = createSSEStream({ requestPayload, userId: authenticatedUserId, streamId: userMessageIdToUse, + executionId, + runId, chatId: actualChatId, currentChat, isNewChat: conversationHistory.length === 0, @@ -295,14 +332,83 @@ export async function POST(req: NextRequest) { titleModel: selectedModel, titleProvider: provider, requestId: tracker.requestId, + workspaceId: resolvedWorkspaceId, orchestrateOptions: { userId: authenticatedUserId, workflowId, chatId: actualChatId, + executionId, + runId, goRoute: '/api/copilot', autoExecuteTools: true, interactive: true, - promptForToolApproval: true, + promptForToolApproval: false, + onComplete: async (result: OrchestratorResult) => { + if (!actualChatId) return + + const assistantMessage: Record = { + id: crypto.randomUUID(), + role: 'assistant' as const, + content: result.content, + timestamp: new Date().toISOString(), + ...(result.requestId ? { requestId: result.requestId } : {}), + } + if (result.toolCalls.length > 0) { + assistantMessage.toolCalls = result.toolCalls + } + if (result.contentBlocks.length > 0) { + assistantMessage.contentBlocks = result.contentBlocks.map((block) => { + const stored: Record = { type: block.type } + if (block.content) stored.content = block.content + if (block.type === 'tool_call' && block.toolCall) { + stored.toolCall = { + id: block.toolCall.id, + name: block.toolCall.name, + state: + block.toolCall.result?.success !== undefined + ? block.toolCall.result.success + ? 'success' + : 'error' + : block.toolCall.status, + result: block.toolCall.result, + ...(block.calledBy ? { calledBy: block.calledBy } : {}), + } + } + return stored + }) + } + + try { + const [row] = await db + .select({ messages: copilotChats.messages }) + .from(copilotChats) + .where(eq(copilotChats.id, actualChatId)) + .limit(1) + + const msgs: any[] = Array.isArray(row?.messages) ? row.messages : [] + const userIdx = msgs.findIndex((m: any) => m.id === userMessageIdToUse) + const alreadyHasResponse = + userIdx >= 0 && + userIdx + 1 < msgs.length && + (msgs[userIdx + 1] as any)?.role === 'assistant' + + if (!alreadyHasResponse) { + await db + .update(copilotChats) + .set({ + messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`, + conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${userMessageIdToUse} THEN NULL ELSE ${copilotChats.conversationId} END`, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, actualChatId)) + } + } catch (error) { + logger.error(`[${tracker.requestId}] Failed to persist chat messages`, { + chatId: actualChatId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + } + }, }, }) @@ -316,7 +422,7 @@ export async function POST(req: NextRequest) { goRoute: '/api/copilot', autoExecuteTools: true, interactive: true, - promptForToolApproval: true, + promptForToolApproval: false, }) const responseData = { diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 70e34c07fc4..35b66fa3eca 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -8,9 +8,11 @@ import { import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' import { SSE_HEADERS } from '@/lib/core/utils/sse' +export const maxDuration = 3600 + const logger = createLogger('CopilotChatStreamAPI') const POLL_INTERVAL_MS = 250 -const MAX_STREAM_MS = 10 * 60 * 1000 +const MAX_STREAM_MS = 60 * 60 * 1000 function encodeEvent(event: Record): Uint8Array { return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) @@ -67,6 +69,8 @@ export async function GET(request: NextRequest) { success: true, events: filteredEvents, status: meta.status, + executionId: meta.executionId, + runId: meta.runId, }) } @@ -75,6 +79,7 @@ export async function GET(request: NextRequest) { const stream = new ReadableStream({ async start(controller) { let lastEventId = Number.isFinite(fromEventId) ? fromEventId : 0 + let latestMeta = meta const flushEvents = async () => { const events = await readStreamEvents(streamId, lastEventId) @@ -91,6 +96,8 @@ export async function GET(request: NextRequest) { ...entry.event, eventId: entry.eventId, streamId: entry.streamId, + executionId: latestMeta?.executionId, + runId: latestMeta?.runId, } controller.enqueue(encodeEvent(payload)) } @@ -102,6 +109,7 @@ export async function GET(request: NextRequest) { while (Date.now() - startTime < MAX_STREAM_MS) { const currentMeta = await getStreamMeta(streamId) if (!currentMeta) break + latestMeta = currentMeta await flushEvents() diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 5910cbb2bd8..7010d84e92b 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -3,14 +3,27 @@ import { copilotChats, permissions, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq, isNull, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' import { authenticateCopilotRequestSessionOnly, + createBadRequestResponse, createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { taskPubSub } from '@/lib/copilot/task-events' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' +import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatsListAPI') +const CreateWorkflowCopilotChatSchema = z.object({ + workspaceId: z.string().min(1), + workflowId: z.string().min(1), +}) + +const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' + export async function GET(_request: NextRequest) { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -24,6 +37,7 @@ export async function GET(_request: NextRequest) { title: copilotChats.title, workflowId: copilotChats.workflowId, workspaceId: copilotChats.workspaceId, + conversationId: copilotChats.conversationId, updatedAt: copilotChats.updatedAt, }) .from(copilotChats) @@ -68,3 +82,60 @@ export async function GET(_request: NextRequest) { return createInternalServerErrorResponse('Failed to fetch user chats') } } + +/** + * POST /api/copilot/chats + * Creates an empty workflow-scoped copilot chat (same lifecycle as {@link resolveOrCreateChat}). + * Matches mothership's POST /api/mothership/chats pattern so the client always selects a real row id. + */ +export async function POST(request: NextRequest) { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const body = await request.json() + const { workspaceId, workflowId } = CreateWorkflowCopilotChatSchema.parse(body) + + await assertActiveWorkspaceAccess(workspaceId, userId) + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + if (!authorization.allowed || !authorization.workflow) { + return NextResponse.json( + { success: false, error: authorization.message ?? 'Forbidden' }, + { status: authorization.status } + ) + } + + if (authorization.workflow.workspaceId !== workspaceId) { + return createBadRequestResponse('workflow does not belong to this workspace') + } + + const result = await resolveOrCreateChat({ + userId, + workflowId, + workspaceId, + model: DEFAULT_COPILOT_MODEL, + type: 'copilot', + }) + + if (!result.chatId) { + return createInternalServerErrorResponse('Failed to create chat') + } + + taskPubSub?.publishStatusChanged({ workspaceId, chatId: result.chatId, type: 'created' }) + + return NextResponse.json({ success: true, id: result.chatId }) + } catch (error) { + if (error instanceof z.ZodError) { + return createBadRequestResponse('workspaceId and workflowId are required') + } + logger.error('Error creating workflow copilot chat:', error) + return createInternalServerErrorResponse('Failed to create chat') + } +} diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index 20f6ecf5a14..e5bd3c9da42 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -240,7 +240,7 @@ describe('Copilot Confirm API Route', () => { }) }) - it('should return 400 when Redis client is not available', async () => { + it('should succeed when Redis client is not available', async () => { setAuthenticated() mockGetRedisClient.mockReturnValue(null) @@ -252,9 +252,15 @@ describe('Copilot Confirm API Route', () => { const response = await POST(req) - expect(response.status).toBe(400) + expect(response.status).toBe(200) const responseData = await response.json() - expect(responseData.error).toBe('Failed to update tool call status or tool call not found') + expect(responseData).toEqual({ + success: true, + message: 'Tool call tool-call-123 has been success', + toolCallId: 'tool-call-123', + status: 'success', + }) + expect(mockRedisSet).not.toHaveBeenCalled() }) it('should return 400 when Redis set fails', async () => { diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index a63333b356e..fd03b83533d 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { completeAsyncToolCall, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { REDIS_TOOL_CALL_PREFIX, REDIS_TOOL_CALL_TTL_SECONDS } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly, @@ -34,10 +35,38 @@ async function updateToolCallStatus( message?: string, data?: Record ): Promise { + const durableStatus = + status === 'success' + ? 'completed' + : status === 'cancelled' + ? 'cancelled' + : status === 'error' || status === 'rejected' + ? 'failed' + : 'pending' + await upsertAsyncToolCall({ + runId: crypto.randomUUID(), + toolCallId, + toolName: 'client_tool', + args: {}, + status: durableStatus, + }).catch(() => {}) + if ( + durableStatus === 'completed' || + durableStatus === 'failed' || + durableStatus === 'cancelled' + ) { + await completeAsyncToolCall({ + toolCallId, + status: durableStatus, + result: data ?? null, + error: status === 'success' ? null : message || status, + }).catch(() => {}) + } + const redis = getRedisClient() if (!redis) { - logger.warn('Redis client not available for tool confirmation') - return false + logger.warn('Redis client not available for tool confirmation; durable DB mirror only') + return true } try { diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 48f85bf21ba..387a2ccee82 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -5,7 +5,7 @@ import '@/lib/uploads/core/setup.server' import { getSession } from '@/lib/auth' import type { StorageContext } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils' +import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_AUDIO_EXTENSIONS, SUPPORTED_DOCUMENT_EXTENSIONS, @@ -280,19 +280,8 @@ export async function POST(request: NextRequest) { continue } - // Handle copilot, chat, profile-pictures contexts if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') { - if (context === 'copilot') { - const { isSupportedFileType: isCopilotSupported } = await import( - '@/lib/uploads/contexts/copilot/copilot-file-manager' - ) - const resolvedType = resolveFileType(file) - if (!isImageFileType(resolvedType) && !isCopilotSupported(resolvedType)) { - throw new InvalidRequestError( - 'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).' - ) - } - } else if (!isImageFileType(file.type)) { + if (context !== 'copilot' && !isImageFileType(file.type)) { throw new InvalidRequestError( `Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads` ) diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index d2021685320..0072271156d 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { restoreKnowledgeBase } from '@/lib/knowledge/service' +import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreKnowledgeBaseAPI') @@ -49,6 +49,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof KnowledgeBaseConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index b9a527431aa..7ae829211d5 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -98,11 +98,15 @@ vi.mock('@sim/db/schema', () => ({ vi.mock('@/lib/audit/log', () => auditMock) -vi.mock('@/lib/knowledge/service', () => ({ - getKnowledgeBaseById: vi.fn(), - updateKnowledgeBase: vi.fn(), - deleteKnowledgeBase: vi.fn(), -})) +vi.mock('@/lib/knowledge/service', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getKnowledgeBaseById: vi.fn(), + updateKnowledgeBase: vi.fn(), + deleteKnowledgeBase: vi.fn(), + } +}) vi.mock('@/app/api/knowledge/utils', () => ({ checkKnowledgeBaseAccess: vi.fn(), diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 7c3075a5d8b..2dcf53701da 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { deleteKnowledgeBase, getKnowledgeBaseById, + KnowledgeBaseConflictError, updateKnowledgeBase, } from '@/lib/knowledge/service' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -166,6 +167,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: throw validationError } } catch (error) { + if (error instanceof KnowledgeBaseConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + logger.error(`[${requestId}] Error updating knowledge base`, error) return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) } diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 02697edad38..362047b646b 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -15,6 +15,7 @@ const { mockGetSession, mockDbChain } = vi.hoisted(() => { where: vi.fn().mockReturnThis(), groupBy: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]), + limit: vi.fn().mockResolvedValue([]), insert: vi.fn().mockReturnThis(), values: vi.fn().mockResolvedValue(undefined), } @@ -113,7 +114,7 @@ describe('Knowledge Base API Route', () => { Object.values(mockDbChain).forEach((fn) => { if (typeof fn === 'function') { fn.mockClear() - if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values) { + if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values && fn !== mockDbChain.limit) { fn.mockReturnThis() } } diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index d6a80bab115..28fe86ef016 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { createKnowledgeBase, getKnowledgeBases, + KnowledgeBaseConflictError, type KnowledgeBaseScope, } from '@/lib/knowledge/service' @@ -149,6 +150,10 @@ export async function POST(req: NextRequest) { throw validationError } } catch (error) { + if (error instanceof KnowledgeBaseConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + logger.error(`[${requestId}] Error creating knowledge base`, error) return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 }) } diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 58c124c00c5..bb3e2e856d5 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -36,11 +36,11 @@ import { const logger = createLogger('CopilotMcpAPI') const mcpRateLimiter = new RateLimiter() -const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5' +const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const maxDuration = 300 +export const maxDuration = 3600 interface CopilotKeyAuthResult { success: boolean @@ -517,7 +517,7 @@ async function handleMcpRequestWithSdk( try { await transport.handleRequest(requestAdapter as any, responseCapture as any, parsedBody) await responseCapture.waitForHeaders() - // Must exceed the longest possible tool execution (build = 5 min). + // Must exceed the longest possible tool execution. // Using ORCHESTRATION_TIMEOUT_MS + 60 s buffer so the orchestrator can // finish or time-out on its own before the transport is torn down. await responseCapture.waitForEnd(ORCHESTRATION_TIMEOUT_MS + 60_000) @@ -630,7 +630,11 @@ async function handleDirectToolCall( userId: string ): Promise { try { - const execContext = await prepareExecutionContext(userId, (args.workflowId as string) || '') + const execContext = await prepareExecutionContext( + userId, + (args.workflowId as string) || '', + (args.chatId as string) || undefined + ) const toolCall = { id: randomUUID(), @@ -729,7 +733,7 @@ async function handleBuildToolCall( chatId, goRoute: '/api/mcp', autoExecuteTools: true, - timeout: 300000, + timeout: ORCHESTRATION_TIMEOUT_MS, interactive: false, abortSignal, }) diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index c1478c172fb..5822e81617a 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -22,6 +22,8 @@ import { getUserEntityPermissions, } from '@/lib/workspaces/permissions/utils' +export const maxDuration = 3600 + const logger = createLogger('MothershipChatAPI') const FileAttachmentSchema = z.object({ @@ -114,6 +116,29 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 403 }) } + let currentChat: any = null + let conversationHistory: any[] = [] + let actualChatId = chatId + + if (chatId || createNewChat) { + const chatResult = await resolveOrCreateChat({ + chatId, + userId: authenticatedUserId, + workspaceId, + model: 'claude-opus-4-6', + type: 'mothership', + }) + currentChat = chatResult.chat + actualChatId = chatResult.chatId || chatId + conversationHistory = Array.isArray(chatResult.conversationHistory) + ? chatResult.conversationHistory + : [] + + if (chatId && !currentChat) { + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) + } + } + let agentContexts: Array<{ type: string; content: string }> = [] if (Array.isArray(contexts) && contexts.length > 0) { try { @@ -121,7 +146,8 @@ export async function POST(req: NextRequest) { contexts as any, authenticatedUserId, message, - workspaceId + workspaceId, + actualChatId ) } catch (e) { logger.error(`[${tracker.requestId}] Failed to process contexts`, e) @@ -135,7 +161,8 @@ export async function POST(req: NextRequest) { r.type, r.id, workspaceId, - authenticatedUserId + authenticatedUserId, + actualChatId ) if (!ctx) return null return { @@ -156,29 +183,6 @@ export async function POST(req: NextRequest) { } } - let currentChat: any = null - let conversationHistory: any[] = [] - let actualChatId = chatId - - if (chatId || createNewChat) { - const chatResult = await resolveOrCreateChat({ - chatId, - userId: authenticatedUserId, - workspaceId, - model: 'claude-opus-4-5', - type: 'mothership', - }) - currentChat = chatResult.chat - actualChatId = chatResult.chatId || chatId - conversationHistory = Array.isArray(chatResult.conversationHistory) - ? chatResult.conversationHistory - : [] - - if (chatId && !currentChat) { - return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) - } - } - if (actualChatId) { const userMsg = { id: userMessageId, @@ -252,21 +256,27 @@ export async function POST(req: NextRequest) { await waitForPendingChatStream(actualChatId) } + const executionId = crypto.randomUUID() + const runId = crypto.randomUUID() const stream = createSSEStream({ requestPayload, userId: authenticatedUserId, streamId: userMessageId, + executionId, + runId, chatId: actualChatId, currentChat, isNewChat: conversationHistory.length === 0, message, - titleModel: 'claude-opus-4-5', + titleModel: 'claude-opus-4-6', requestId: tracker.requestId, workspaceId, orchestrateOptions: { userId: authenticatedUserId, workspaceId, chatId: actualChatId, + executionId, + runId, goRoute: '/api/mothership', autoExecuteTools: true, interactive: true, diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index f9b4e1748c2..91177b0a9d6 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -86,7 +86,7 @@ export async function POST(request: NextRequest) { workspaceId, type: 'mothership', title: null, - model: 'claude-opus-4-5', + model: 'claude-opus-4-6', messages: [], updatedAt: now, lastSeenAt: now, diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index f7f2e72d71d..6ec8bc33e24 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -10,6 +10,8 @@ import { getUserEntityPermissions, } from '@/lib/workspaces/permissions/utils' +export const maxDuration = 3600 + const logger = createLogger('MothershipExecuteAPI') const MessageSchema = z.object({ diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index 8622f849f1d..9eb8feb782f 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { getTableById, restoreTable } from '@/lib/table' +import { getTableById, restoreTable, TableConflictError } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreTableAPI') @@ -36,6 +36,10 @@ export async function POST( return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof TableConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + logger.error(`[${requestId}] Error restoring table ${tableId}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 2341c9f8ad1..30a99c951b3 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -3,7 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { deleteTable, NAME_PATTERN, renameTable, TABLE_LIMITS, type TableSchema } from '@/lib/table' +import { + deleteTable, + NAME_PATTERN, + renameTable, + TABLE_LIMITS, + TableConflictError, + type TableSchema, +} from '@/lib/table' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableDetailAPI') @@ -136,6 +143,10 @@ export async function PATCH(request: NextRequest, { params }: TableRouteParams) ) } + if (error instanceof TableConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + logger.error(`[${requestId}] Error renaming table:`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to rename table' }, diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index e3acb50a10a..2c60a0a9a22 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -6,8 +6,10 @@ import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { authenticateV1Request } from '@/app/api/v1/auth' +export const maxDuration = 3600 + const logger = createLogger('CopilotHeadlessAPI') -const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5' +const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' const RequestSchema = z.object({ message: z.string().min(1, 'message is required'), @@ -17,7 +19,7 @@ const RequestSchema = z.object({ mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), model: z.string().optional(), autoExecuteTools: z.boolean().optional().default(true), - timeout: z.number().optional().default(300000), + timeout: z.number().optional().default(3_600_000), }) /** diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index f9d8228e4f1..8c344c1575d 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { + FileConflictError, getWorkspaceFile, listWorkspaceFiles, uploadWorkspaceFile, @@ -182,7 +183,8 @@ export async function POST(request: NextRequest) { }) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to upload file' - const isDuplicate = errorMessage.includes('already exists') + const isDuplicate = + error instanceof FileConflictError || errorMessage.includes('already exists') if (isDuplicate) { return NextResponse.json({ error: errorMessage }, { status: 409 }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index c35f2830603..ac87eb5811d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -42,7 +42,7 @@ export async function POST( const { getBaseUrl } = await import('@/lib/core/utils/urls') const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace` - const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}/view` + const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}` logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index eae4bae4368..aed633a0832 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' -import { restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { FileConflictError, restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkspaceFileAPI') @@ -31,6 +31,9 @@ export async function POST( return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof FileConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 18f4faa2afd..9c1bc89cbc5 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -4,6 +4,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { + FileConflictError, listWorkspaceFiles, uploadWorkspaceFile, type WorkspaceFileScope, @@ -135,9 +136,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } catch (error) { logger.error(`[${requestId}] Error uploading workspace file:`, error) - // Check if it's a duplicate file error const errorMessage = error instanceof Error ? error.message : 'Failed to upload file' - const isDuplicate = errorMessage.includes('already exists') + const isDuplicate = + error instanceof FileConflictError || errorMessage.includes('already exists') return NextResponse.json( { diff --git a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx new file mode 100644 index 00000000000..587e4e6b384 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx @@ -0,0 +1,39 @@ +'use client' + +import type { ReactNode } from 'react' +import { Blimp } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' + +interface ConversationListItemProps { + title: string + isActive?: boolean + isUnread?: boolean + className?: string + titleClassName?: string + actions?: ReactNode +} + +export function ConversationListItem({ + title, + isActive = false, + isUnread = false, + className, + titleClassName, + actions, +}: ConversationListItemProps) { + return ( +
+ + + {isActive && ( + + )} + {!isActive && isUnread && ( + + )} + + {title} + {actions &&
{actions}
} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 28ae1e475a5..415055b23e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,3 +1,4 @@ +export { ConversationListItem } from './conversation-list-item' export { ErrorState, type ErrorStateProps } from './error' export { InlineRenameInput } from './inline-rename-input' export { MessageActions } from './message-actions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx new file mode 100644 index 00000000000..0e8dc9b4758 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { Files } from '../files' + +export const metadata: Metadata = { + title: 'Files', + robots: { index: false }, +} + +interface FileDetailPageProps { + params: Promise<{ + workspaceId: string + fileId: string + }> +} + +export default async function FileDetailPage({ params }: FileDetailPageProps) { + const { workspaceId } = await params + const session = await getSession() + + if (!session?.user?.id) { + redirect('/') + } + + const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!hasPermission) { + redirect('/') + } + + const permissionConfig = await getUserPermissionConfig(session.user.id) + if (permissionConfig?.hideFilesTab) { + redirect(`/workspace/${workspaceId}`) + } + + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 0e8f9b62a6b..4b4029fa329 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -11,6 +11,7 @@ import { useWorkspaceFileContent, } from '@/hooks/queries/workspace-files' import { useAutosave } from '@/hooks/use-autosave' +import { useStreamingText } from '@/hooks/use-streaming-text' import { PreviewPanel, resolvePreviewType } from './preview-panel' const logger = createLogger('FileViewer') @@ -77,6 +78,7 @@ interface FileViewerProps { onDirtyChange?: (isDirty: boolean) => void onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void saveRef?: React.MutableRefObject<(() => Promise) | null> + streamingContent?: string } export function FileViewer({ @@ -89,6 +91,7 @@ export function FileViewer({ onDirtyChange, onSaveStatusChange, saveRef, + streamingContent, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -97,12 +100,13 @@ export function FileViewer({ ) } @@ -123,6 +127,7 @@ interface TextEditorProps { onDirtyChange?: (isDirty: boolean) => void onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void saveRef?: React.MutableRefObject<(() => Promise) | null> + streamingContent?: string } function TextEditor({ @@ -134,6 +139,7 @@ function TextEditor({ onDirtyChange, onSaveStatusChange, saveRef, + streamingContent, }: TextEditorProps) { const initializedRef = useRef(false) const contentRef = useRef('') @@ -157,6 +163,13 @@ function TextEditor({ const savedContentRef = useRef('') useEffect(() => { + if (streamingContent !== undefined) { + setContent(streamingContent) + contentRef.current = streamingContent + initializedRef.current = true + return + } + if (fetchedContent === undefined) return if (!initializedRef.current) { @@ -180,7 +193,7 @@ function TextEditor({ savedContentRef.current = fetchedContent contentRef.current = fetchedContent } - }, [fetchedContent, dataUpdatedAt, autoFocus]) + }, [streamingContent, fetchedContent, dataUpdatedAt, autoFocus]) const handleContentChange = useCallback((value: string) => { setContent(value) @@ -249,23 +262,28 @@ function TextEditor({ } }, [isResizing]) - if (isLoading) { - return ( -
- - - - -
- ) - } + const isStreaming = streamingContent !== undefined + const revealedContent = useStreamingText(content, isStreaming) + + if (streamingContent === undefined) { + if (isLoading) { + return ( +
+ + + + +
+ ) + } - if (error) { - return ( -
-

Failed to load file content

-
- ) + if (error) { + return ( +
+

Failed to load file content

+
+ ) + } } const showEditor = previewMode !== 'preview' @@ -276,7 +294,7 @@ function TextEditor({ {showEditor && (