From 35b3646330f9272f0ba1e946017a35157d9788b4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 11:02:33 -0700 Subject: [PATCH 01/15] fix(sidebar): cmd+click opens in new tab, shift+click for range select (#3846) * fix(sidebar): cmd+click opens in new tab, shift+click for range select * comment cleanup * fix(sidebar): drop stale metaKey param from workflow and task selection hooks --- .../components/workflow-item/workflow-item.tsx | 10 ++++++---- .../w/components/sidebar/hooks/use-task-selection.ts | 11 +++++------ .../sidebar/hooks/use-workflow-selection.ts | 11 ++++------- .../[workspaceId]/w/components/sidebar/sidebar.tsx | 9 ++++----- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index c6f296f5626..37f276ff686 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -35,7 +35,7 @@ interface WorkflowItemProps { active: boolean level: number dragDisabled?: boolean - onWorkflowClick: (workflowId: string, shiftKey: boolean, metaKey: boolean) => void + onWorkflowClick: (workflowId: string, shiftKey: boolean) => void onDragStart?: () => void onDragEnd?: () => void } @@ -368,13 +368,15 @@ export function WorkflowItem({ return } - const isModifierClick = e.shiftKey || e.metaKey || e.ctrlKey + if (e.metaKey || e.ctrlKey) { + return + } - if (isModifierClick) { + if (e.shiftKey) { e.preventDefault() } - onWorkflowClick(workflow.id, e.shiftKey, e.metaKey || e.ctrlKey) + onWorkflowClick(workflow.id, e.shiftKey) }, [shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-task-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-task-selection.ts index df9d8bc5909..41dd7d0b806 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-task-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-task-selection.ts @@ -9,8 +9,9 @@ interface UseTaskSelectionProps { } /** - * Hook for managing task selection with support for single, range, and toggle selection. - * Handles shift-click for range selection and cmd/ctrl-click for toggle. + * Hook for managing task selection with support for single and range selection. + * Handles shift-click for range selection. + * cmd/ctrl+click is handled by the browser (opens in new tab) and never reaches this handler. * Uses the last selected task as the anchor point for range selections. * Selecting tasks clears workflow/folder selections and vice versa. */ @@ -18,16 +19,14 @@ export function useTaskSelection({ taskIds }: UseTaskSelectionProps) { const selectedTasks = useFolderStore((s) => s.selectedTasks) const handleTaskClick = useCallback( - (taskId: string, shiftKey: boolean, metaKey: boolean) => { + (taskId: string, shiftKey: boolean) => { const { selectTaskOnly, selectTaskRange, toggleTaskSelection, lastSelectedTaskId: anchor, } = useFolderStore.getState() - if (metaKey) { - toggleTaskSelection(taskId) - } else if (shiftKey && anchor && anchor !== taskId) { + if (shiftKey && anchor && anchor !== taskId) { selectTaskRange(taskIds, anchor, taskId) } else if (shiftKey) { toggleTaskSelection(taskId) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts index 3fc12310a6f..2e3d56e4c02 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts @@ -60,18 +60,15 @@ export function useWorkflowSelection({ }, [workflowAncestorFolderIds]) /** - * Handle workflow click with support for shift-click range selection and cmd/ctrl-click toggle. + * Handle workflow click with support for shift-click range selection. + * cmd/ctrl+click is handled by the browser (opens in new tab) and never reaches this handler. * * @param workflowId - ID of clicked workflow * @param shiftKey - Whether shift key was pressed - * @param metaKey - Whether cmd (Mac) or ctrl (Windows) key was pressed */ const handleWorkflowClick = useCallback( - (workflowId: string, shiftKey: boolean, metaKey: boolean) => { - if (metaKey) { - toggleWorkflowSelection(workflowId) - deselectConflictingFolders() - } else if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) { + (workflowId: string, shiftKey: boolean) => { + if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) { selectRange(workflowIds, activeWorkflowId, workflowId) deselectConflictingFolders() } else if (shiftKey) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 1190f0a390f..e3fbffd961a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -151,7 +151,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ isUnread: boolean isMenuOpen: boolean showCollapsedTooltips: boolean - onMultiSelectClick: (taskId: string, shiftKey: boolean, metaKey: boolean) => void + onMultiSelectClick: (taskId: string, shiftKey: boolean) => void onContextMenu: (e: React.MouseEvent, taskId: string) => void onMorePointerDown: () => void onMoreClick: (e: React.MouseEvent, taskId: string) => void @@ -167,9 +167,10 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ )} onClick={(e) => { if (task.id === 'new') return - if (e.shiftKey || e.metaKey || e.ctrlKey) { + if (e.metaKey || e.ctrlKey) return + if (e.shiftKey) { e.preventDefault() - onMultiSelectClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey) + onMultiSelectClick(task.id, true) } else { useFolderStore.setState({ selectedTasks: new Set(), @@ -1058,8 +1059,6 @@ export const Sidebar = memo(function Sidebar() { [handleCreateWorkflow] ) - const noop = useCallback(() => {}, []) - const handleExpandSidebar = useCallback( (e: React.MouseEvent) => { e.preventDefault() From e1359b09d60b9deeef0c8534ee6dc7baeb03a5c4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 30 Mar 2026 15:08:51 -0700 Subject: [PATCH 02/15] feat(block) add block write and append operations (#3665) * Add file write and delete operations * Add file block write operation * Fix lint * Allow loop-in-loop workflow edits * Fix type error * Remove file id input, output link correctly * Add append tool * fix lint * Address feedback * Handle writing to same file name gracefully * Removed mime type from append block * Add lock for file append operation --------- Co-authored-by: Theodore Li --- apps/sim/app/api/tools/file/manage/route.ts | 166 ++++++++++++++++++ apps/sim/blocks/blocks/file.ts | 125 +++++++++++-- .../workspace/workspace-file-manager.ts | 47 ++++- apps/sim/tools/file/append.ts | 58 ++++++ apps/sim/tools/file/index.ts | 3 + apps/sim/tools/file/write.ts | 68 +++++++ apps/sim/tools/registry.ts | 10 +- 7 files changed, 461 insertions(+), 16 deletions(-) create mode 100644 apps/sim/app/api/tools/file/manage/route.ts create mode 100644 apps/sim/tools/file/append.ts create mode 100644 apps/sim/tools/file/write.ts diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts new file mode 100644 index 00000000000..9b57cf5c379 --- /dev/null +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -0,0 +1,166 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' +import { + downloadWorkspaceFile, + getWorkspaceFileByName, + updateWorkspaceFileContent, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('FileManageAPI') + +export async function POST(request: NextRequest) { + const auth = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success) { + return NextResponse.json({ success: false, error: auth.error }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const userId = auth.userId || searchParams.get('userId') + + if (!userId) { + return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 }) + } + + let body: Record + try { + body = await request.json() + } catch { + return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 }) + } + + const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 }) + } + + const operation = body.operation as string + + try { + switch (operation) { + case 'write': { + const fileName = body.fileName as string | undefined + const content = body.content as string | undefined + const contentType = body.contentType as string | undefined + + if (!fileName) { + return NextResponse.json( + { success: false, error: 'fileName is required for write operation' }, + { status: 400 } + ) + } + + if (!content && content !== '') { + return NextResponse.json( + { success: false, error: 'content is required for write operation' }, + { status: 400 } + ) + } + + const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) + const fileBuffer = Buffer.from(content ?? '', 'utf-8') + const result = await uploadWorkspaceFile( + workspaceId, + userId, + fileBuffer, + fileName, + mimeType + ) + + logger.info('File created', { + fileId: result.id, + name: fileName, + size: fileBuffer.length, + }) + + return NextResponse.json({ + success: true, + data: { + id: result.id, + name: result.name, + size: fileBuffer.length, + url: ensureAbsoluteUrl(result.url), + }, + }) + } + + case 'append': { + const fileName = body.fileName as string | undefined + const content = body.content as string | undefined + + if (!fileName) { + return NextResponse.json( + { success: false, error: 'fileName is required for append operation' }, + { status: 400 } + ) + } + + if (!content && content !== '') { + return NextResponse.json( + { success: false, error: 'content is required for append operation' }, + { status: 400 } + ) + } + + const existing = await getWorkspaceFileByName(workspaceId, fileName) + if (!existing) { + return NextResponse.json( + { success: false, error: `File not found: "${fileName}"` }, + { status: 404 } + ) + } + + const lockKey = `file-append:${workspaceId}:${existing.id}` + const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}` + const acquired = await acquireLock(lockKey, lockValue, 30) + if (!acquired) { + return NextResponse.json( + { success: false, error: 'File is busy, please retry' }, + { status: 409 } + ) + } + + try { + const existingBuffer = await downloadWorkspaceFile(existing) + const finalContent = existingBuffer.toString('utf-8') + content + const fileBuffer = Buffer.from(finalContent, 'utf-8') + await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer) + + logger.info('File appended', { + fileId: existing.id, + name: existing.name, + size: fileBuffer.length, + }) + + return NextResponse.json({ + success: true, + data: { + id: existing.id, + name: existing.name, + size: fileBuffer.length, + url: ensureAbsoluteUrl(existing.path), + }, + }) + } finally { + await releaseLock(lockKey, lockValue) + } + } + + default: + return NextResponse.json( + { success: false, error: `Unknown operation: ${operation}. Supported: write, append` }, + { status: 400 } + ) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('File operation failed', { operation, error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 9e6b14b7d9b..bf377179645 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -250,9 +250,9 @@ export const FileV2Block: BlockConfig = { export const FileV3Block: BlockConfig = { type: 'file_v3', name: 'File', - description: 'Read and parse multiple files', + description: 'Read and write workspace files', longDescription: - 'Upload files directly or import from external URLs to get UserFile objects for use in other blocks.', + 'Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.', docsLink: 'https://docs.sim.ai/tools/file', category: 'tools', integrationType: IntegrationType.FileStorage, @@ -260,6 +260,17 @@ export const FileV3Block: BlockConfig = { bgColor: '#40916C', icon: DocumentIcon, subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown' as SubBlockType, + options: [ + { label: 'Read', id: 'file_parser_v3' }, + { label: 'Write', id: 'file_write' }, + { label: 'Append', id: 'file_append' }, + ], + value: () => 'file_parser_v3', + }, { id: 'file', title: 'Files', @@ -270,7 +281,8 @@ export const FileV3Block: BlockConfig = { multiple: true, mode: 'basic', maxSize: 100, - required: true, + required: { field: 'operation', value: 'file_parser_v3' }, + condition: { field: 'operation', value: 'file_parser_v3' }, }, { id: 'fileUrl', @@ -279,15 +291,84 @@ export const FileV3Block: BlockConfig = { canonicalParamId: 'fileInput', placeholder: 'https://example.com/document.pdf', mode: 'advanced', - required: true, + required: { field: 'operation', value: 'file_parser_v3' }, + condition: { field: 'operation', value: 'file_parser_v3' }, + }, + { + id: 'fileName', + title: 'File Name', + type: 'short-input' as SubBlockType, + placeholder: 'File name (e.g., data.csv)', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'File content to write...', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'contentType', + title: 'Content Type', + type: 'short-input' as SubBlockType, + placeholder: 'text/plain (auto-detected from extension)', + condition: { field: 'operation', value: 'file_write' }, + mode: 'advanced', + }, + { + id: 'appendFileName', + title: 'File', + type: 'dropdown' as SubBlockType, + placeholder: 'Select a workspace file...', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + options: [], + fetchOptions: async () => { + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + const response = await fetch(`/api/workspaces/${workspaceId}/files`) + const data = await response.json() + if (!data.success || !data.files) return [] + return data.files.map((f: { name: string }) => ({ label: f.name, id: f.name })) + }, + }, + { + id: 'appendContent', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'Content to append...', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, }, ], tools: { - access: ['file_parser_v3'], + access: ['file_parser_v3', 'file_write', 'file_append'], config: { - tool: () => 'file_parser_v3', + tool: (params) => params.operation || 'file_parser_v3', params: (params) => { - // Use canonical 'fileInput' param directly + const operation = params.operation || 'file_parser_v3' + + if (operation === 'file_write') { + return { + fileName: params.fileName, + content: params.content, + contentType: params.contentType, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_append') { + return { + fileName: params.appendFileName, + content: params.appendContent, + workspaceId: params._context?.workspaceId, + } + } + const fileInput = params.fileInput if (!fileInput) { logger.error('No file input provided') @@ -326,17 +407,39 @@ export const FileV3Block: BlockConfig = { }, }, inputs: { - fileInput: { type: 'json', description: 'File input (canonical param)' }, - fileType: { type: 'string', description: 'File type' }, + operation: { type: 'string', description: 'Operation to perform (read, write, or append)' }, + fileInput: { type: 'json', description: 'File input for read (canonical param)' }, + fileType: { type: 'string', description: 'File type for read' }, + 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' }, + appendFileName: { type: 'string', description: 'Name of existing file to append to' }, + appendContent: { type: 'string', description: 'Content to append to file' }, }, outputs: { files: { type: 'file[]', - description: 'Parsed files as UserFile objects', + description: 'Parsed files as UserFile objects (read)', }, combinedContent: { type: 'string', - description: 'All file contents merged into a single text string', + description: 'All file contents merged into a single text string (read)', + }, + id: { + type: 'string', + description: 'File ID (write)', + }, + name: { + type: 'string', + description: 'File name (write)', + }, + size: { + type: 'number', + description: 'File size in bytes (write)', + }, + url: { + type: 'string', + description: 'URL to access the file (write)', }, }, } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index e247231c411..1c69405ffe9 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -15,8 +15,10 @@ import { import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { getPostgresErrorCode } from '@/lib/core/utils/pg-error' import { generateRestoreName } from '@/lib/core/utils/restore-name' +import { getServePathPrefix } from '@/lib/uploads' import { downloadFile, hasCloudStorage, uploadFile } from '@/lib/uploads/core/storage-service' import { getFileMetadataByKey, insertFileMetadata } from '@/lib/uploads/server/metadata' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { isUuid, sanitizeFileName } from '@/executor/constants' import type { UserFile } from '@/executor/types' @@ -221,7 +223,6 @@ export async function uploadWorkspaceFile( logger.error(`Failed to update storage tracking:`, storageError) } - const { getServePathPrefix } = await import('@/lib/uploads') const pathPrefix = getServePathPrefix() const serveUrl = `${pathPrefix}${encodeURIComponent(uploadResult.key)}?context=workspace` @@ -336,6 +337,47 @@ export async function fileExistsInWorkspace( } } +/** + * Look up a single active workspace file by its original name. + * Returns the record if found, or null if no matching file exists. + * Throws on DB errors so callers can distinguish "not found" from "lookup failed." + */ +export async function getWorkspaceFileByName( + workspaceId: string, + fileName: string +): Promise { + const files = await db + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.originalName, fileName), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + + if (files.length === 0) return null + + const pathPrefix = getServePathPrefix() + + const file = files[0] + return { + id: file.id, + workspaceId: file.workspaceId || workspaceId, + name: file.originalName, + key: file.key, + path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`, + size: file.size, + type: file.contentType, + uploadedBy: file.userId, + deletedAt: file.deletedAt, + uploadedAt: file.uploadedAt, + } +} + /** * List all files for a workspace */ @@ -368,7 +410,6 @@ export async function listWorkspaceFiles( ) .orderBy(workspaceFiles.uploadedAt) - const { getServePathPrefix } = await import('@/lib/uploads') const pathPrefix = getServePathPrefix() return files.map((file) => ({ @@ -493,7 +534,6 @@ export async function getWorkspaceFile( if (files.length === 0) return null - const { getServePathPrefix } = await import('@/lib/uploads') const pathPrefix = getServePathPrefix() const file = files[0] @@ -731,7 +771,6 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): throw new Error('File is not archived') } - const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { throw new Error('Cannot restore file into an archived workspace') diff --git a/apps/sim/tools/file/append.ts b/apps/sim/tools/file/append.ts new file mode 100644 index 00000000000..63ac9eaa99e --- /dev/null +++ b/apps/sim/tools/file/append.ts @@ -0,0 +1,58 @@ +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileAppendParams { + fileName: string + content: string + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileAppendTool: ToolConfig = { + id: 'file_append', + name: 'File Append', + description: + 'Append content to an existing workspace file. The file must already exist. Content is added to the end of the file.', + version: '1.0.0', + + params: { + fileName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of an existing workspace file to append to.', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The text content to append to the file.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'append', + fileName: params.fileName, + content: params.content, + 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 append to file' } + } + return { success: true, output: data.data } + }, + + outputs: { + id: { type: 'string', description: 'File ID' }, + name: { type: 'string', description: 'File name' }, + size: { type: 'number', description: 'File size in bytes' }, + url: { type: 'string', description: 'URL to access the file', optional: true }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 6714c7dddc4..2a60ea594f1 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -1,5 +1,8 @@ import { fileParserTool, fileParserV2Tool, fileParserV3Tool } from '@/tools/file/parser' +export { fileAppendTool } from '@/tools/file/append' +export { fileWriteTool } from '@/tools/file/write' + export const fileParseTool = fileParserTool export { fileParserV2Tool } export { fileParserV3Tool } diff --git a/apps/sim/tools/file/write.ts b/apps/sim/tools/file/write.ts new file mode 100644 index 00000000000..cf49b9e735b --- /dev/null +++ b/apps/sim/tools/file/write.ts @@ -0,0 +1,68 @@ +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileWriteParams { + fileName: string + content: string + contentType?: string + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileWriteTool: ToolConfig = { + id: 'file_write', + name: 'File Write', + description: + 'Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv").', + version: '1.0.0', + + params: { + fileName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'File name (e.g., "data.csv"). If a file with this name exists, a numeric suffix is added automatically.', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The text content to write to the file.', + }, + contentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'MIME type for new files (e.g., "text/plain"). Auto-detected from file extension if omitted.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'write', + fileName: params.fileName, + content: params.content, + contentType: params.contentType, + 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 write file' } + } + return { success: true, output: data.data } + }, + + outputs: { + id: { type: 'string', description: 'File ID' }, + name: { type: 'string', description: 'File name' }, + size: { type: 'number', description: 'File size in bytes' }, + url: { type: 'string', description: 'URL to access the file', optional: true }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 66d70a07f7b..2ba6281a52d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -498,7 +498,13 @@ import { fathomListTeamMembersTool, fathomListTeamsTool, } from '@/tools/fathom' -import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file' +import { + fileAppendTool, + fileParserV2Tool, + fileParserV3Tool, + fileParseTool, + fileWriteTool, +} from '@/tools/file' import { firecrawlAgentTool, firecrawlCrawlTool, @@ -2603,6 +2609,8 @@ export const tools: Record = { file_parser: fileParseTool, file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, + file_append: fileAppendTool, + file_write: fileWriteTool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool, firecrawl_crawl: firecrawlCrawlTool, From 1d59eca90a39d5cea4dfab04b3621994ffc9bcc4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 15:36:41 -0700 Subject: [PATCH 03/15] fix(analytics): use getBaseDomain for Profound host field (#3848) request.url resolves to internal ALB hostname on ECS, not the public domain --- apps/sim/lib/analytics/profound.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/analytics/profound.ts b/apps/sim/lib/analytics/profound.ts index 6ad00743fbb..7b2183dcb16 100644 --- a/apps/sim/lib/analytics/profound.ts +++ b/apps/sim/lib/analytics/profound.ts @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' +import { getBaseDomain } from '@/lib/core/utils/urls' const logger = createLogger('ProfoundAnalytics') @@ -97,7 +98,7 @@ export function sendToProfound(request: Request, statusCode: number): void { buffer.push({ timestamp: new Date().toISOString(), method: request.method, - host: url.hostname, + host: getBaseDomain(), path: url.pathname, status_code: statusCode, ip: From d3d58a96155099202cd68f3f3cc9776bb7665b1f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 30 Mar 2026 16:02:17 -0700 Subject: [PATCH 04/15] Feat/improved logging (#3833) * feat(logs): add additional metadata for workflow execution logs * Revert "Feat(logs) upgrade mothership chat messages to error (#3772)" This reverts commit 9d1b9763c56e5bd336d8aa37353aba549f7d34fd. * Fix lint, address greptile comments * improvement(sidebar): expand sidebar by hovering and clicking the edge (#3830) * improvement(sidebar): expand sidebar by hovering and clicking the edge * improvement(sidebar): add keyboard shortcuts for new workflow/task, center search modal, fix edge ARIA * improvement(sidebar): use Tooltip.Shortcut for inline shortcut display * fix(sidebar): change new workflow shortcut from Mod+Shift+W to Mod+Shift+P to avoid browser close-window conflict * fix(hotkeys): fall back to event.code for international keyboard layout compatibility * fix(sidebar): guard add-workflow shortcut with canEdit and isCreatingWorkflow checks * feat(ui): handle image paste (#3826) * feat(ui): handle image paste * Fix lint * Fix type error --------- Co-authored-by: Theodore Li * feat(files): interactive markdown checkbox toggling in preview (#3829) * feat(files): interactive markdown checkbox toggling in preview * fix(files): handle ordered-list checkboxes and fix index drift * lint * fix(files): remove counter offset that prevented checkbox toggling * fix(files): apply task-list styling to ordered lists too * fix(files): render single pass when interactive to avoid index drift * fix(files): move useMemo above conditional return to fix Rules of Hooks * fix(files): pass content directly to preview when not streaming to avoid stale frame * improvement(home): position @ mention popup at caret and fix icon consistency (#3831) * improvement(home): position @ mention popup at caret and fix icon consistency * fix(home): pin mirror div to document origin and guard button anchor * chore(auth): restore hybrid.ts to staging * improvement(ui): sidebar (#3832) * Fix logger tests * Add metadata to mothership logs --------- Co-authored-by: Theodore Li Co-authored-by: Waleed Co-authored-by: Theodore Li --- apps/sim/app/api/copilot/chat/route.ts | 239 ++++++------------ apps/sim/app/api/copilot/chat/stream/route.ts | 39 ++- apps/sim/app/api/mothership/chat/route.ts | 78 ++---- .../api/mothership/chats/[chatId]/route.ts | 12 +- apps/sim/app/api/mothership/execute/route.ts | 6 +- apps/sim/app/api/v1/copilot/chat/route.ts | 23 +- .../[id]/execute/route.async.test.ts | 10 +- .../app/api/workflows/[id]/execute/route.ts | 86 +++---- apps/sim/executor/execution/block-executor.ts | 35 ++- apps/sim/executor/execution/engine.ts | 42 +-- apps/sim/executor/execution/executor.ts | 28 +- apps/sim/lib/copilot/chat-payload.test.ts | 10 +- apps/sim/lib/copilot/chat-payload.ts | 48 ++-- apps/sim/lib/copilot/chat-streaming.ts | 153 ++++------- apps/sim/lib/copilot/orchestrator/index.ts | 173 +++++-------- .../orchestrator/sse/handlers/handlers.ts | 156 ++++-------- .../sse/handlers/tool-execution.ts | 181 +++++-------- .../lib/copilot/orchestrator/stream/core.ts | 20 +- .../orchestrator/tool-executor/index.ts | 75 ++---- .../files/download-to-workspace-file.ts | 8 +- .../tools/server/files/workspace-file.ts | 20 +- .../tools/server/image/generate-image.ts | 18 +- .../copilot/tools/server/jobs/get-job-logs.ts | 8 +- .../tools/server/knowledge/knowledge-base.ts | 42 ++- apps/sim/lib/copilot/tools/server/router.ts | 3 +- .../copilot/tools/server/table/user-table.ts | 14 +- .../visualization/generate-visualization.ts | 28 +- apps/sim/lib/logs/execution/logger.ts | 44 ++-- packages/logger/src/index.test.ts | 59 +++++ packages/logger/src/index.ts | 32 ++- packages/testing/src/mocks/logger.mock.ts | 2 + 31 files changed, 720 insertions(+), 972 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index e14b3d715eb..c1938b5f06c 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -15,7 +15,6 @@ import { requestChatTitle, SSE_RESPONSE_HEADERS, } from '@/lib/copilot/chat-streaming' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer' @@ -184,36 +183,31 @@ export async function POST(req: NextRequest) { const wf = await getWorkflowById(workflowId) resolvedWorkspaceId = wf?.workspaceId ?? undefined } catch { - logger.warn( - appendCopilotLogContext('Failed to resolve workspaceId from workflow', { - requestId: tracker.requestId, - messageId: userMessageId, - }) - ) + logger + .withMetadata({ requestId: tracker.requestId, messageId: userMessageId }) + .warn('Failed to resolve workspaceId from workflow') } const userMessageIdToUse = userMessageId || crypto.randomUUID() + const reqLogger = logger.withMetadata({ + requestId: tracker.requestId, + messageId: userMessageIdToUse, + }) try { - logger.error( - appendCopilotLogContext('Received chat POST', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - { - workflowId, - hasContexts: Array.isArray(normalizedContexts), - contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0, - contextsPreview: Array.isArray(normalizedContexts) - ? normalizedContexts.map((c: any) => ({ - kind: c?.kind, - chatId: c?.chatId, - workflowId: c?.workflowId, - executionId: (c as any)?.executionId, - label: c?.label, - })) - : undefined, - } - ) + reqLogger.info('Received chat POST', { + workflowId, + hasContexts: Array.isArray(normalizedContexts), + contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0, + contextsPreview: Array.isArray(normalizedContexts) + ? normalizedContexts.map((c: any) => ({ + kind: c?.kind, + chatId: c?.chatId, + workflowId: c?.workflowId, + executionId: (c as any)?.executionId, + label: c?.label, + })) + : undefined, + }) } catch {} let currentChat: any = null @@ -251,40 +245,22 @@ export async function POST(req: NextRequest) { actualChatId ) agentContexts = processed - logger.error( - appendCopilotLogContext('Contexts processed for request', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - { - processedCount: agentContexts.length, - kinds: agentContexts.map((c) => c.type), - lengthPreview: agentContexts.map((c) => c.content?.length ?? 0), - } - ) + reqLogger.info('Contexts processed for request', { + processedCount: agentContexts.length, + kinds: agentContexts.map((c) => c.type), + lengthPreview: agentContexts.map((c) => c.content?.length ?? 0), + }) if ( Array.isArray(normalizedContexts) && normalizedContexts.length > 0 && agentContexts.length === 0 ) { - logger.warn( - appendCopilotLogContext( - 'Contexts provided but none processed. Check executionId for logs contexts.', - { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - } - ) + reqLogger.warn( + 'Contexts provided but none processed. Check executionId for logs contexts.' ) } } catch (e) { - logger.error( - appendCopilotLogContext('Failed to process contexts', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - e - ) + reqLogger.error('Failed to process contexts', e) } } @@ -313,13 +289,7 @@ export async function POST(req: NextRequest) { if (result.status === 'fulfilled' && result.value) { agentContexts.push(result.value) } else if (result.status === 'rejected') { - logger.error( - appendCopilotLogContext('Failed to resolve resource attachment', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - result.reason - ) + reqLogger.error('Failed to resolve resource attachment', result.reason) } } } @@ -358,26 +328,20 @@ export async function POST(req: NextRequest) { ) try { - logger.error( - appendCopilotLogContext('About to call Sim Agent', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - { - hasContext: agentContexts.length > 0, - contextCount: agentContexts.length, - hasFileAttachments: Array.isArray(requestPayload.fileAttachments), - messageLength: message.length, - mode: effectiveMode, - hasTools: Array.isArray(requestPayload.tools), - toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0, - hasBaseTools: Array.isArray(requestPayload.baseTools), - baseToolCount: Array.isArray(requestPayload.baseTools) - ? requestPayload.baseTools.length - : 0, - hasCredentials: !!requestPayload.credentials, - } - ) + reqLogger.info('About to call Sim Agent', { + hasContext: agentContexts.length > 0, + contextCount: agentContexts.length, + hasFileAttachments: Array.isArray(requestPayload.fileAttachments), + messageLength: message.length, + mode: effectiveMode, + hasTools: Array.isArray(requestPayload.tools), + toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0, + hasBaseTools: Array.isArray(requestPayload.baseTools), + baseToolCount: Array.isArray(requestPayload.baseTools) + ? requestPayload.baseTools.length + : 0, + hasCredentials: !!requestPayload.credentials, + }) } catch {} if (stream && actualChatId) { @@ -521,16 +485,10 @@ export async function POST(req: NextRequest) { .where(eq(copilotChats.id, actualChatId)) } } catch (error) { - logger.error( - appendCopilotLogContext('Failed to persist chat messages', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - { - chatId: actualChatId, - error: error instanceof Error ? error.message : 'Unknown error', - } - ) + reqLogger.error('Failed to persist chat messages', { + chatId: actualChatId, + error: error instanceof Error ? error.message : 'Unknown error', + }) } }, }, @@ -572,19 +530,13 @@ export async function POST(req: NextRequest) { provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined, } - logger.error( - appendCopilotLogContext('Non-streaming response from orchestrator', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - { - hasContent: !!responseData.content, - contentLength: responseData.content?.length || 0, - model: responseData.model, - provider: responseData.provider, - toolCallsCount: responseData.toolCalls?.length || 0, - } - ) + reqLogger.info('Non-streaming response from orchestrator', { + hasContent: !!responseData.content, + contentLength: responseData.content?.length || 0, + model: responseData.model, + provider: responseData.provider, + toolCallsCount: responseData.toolCalls?.length || 0, + }) // Save messages if we have a chat if (currentChat && responseData.content) { @@ -617,12 +569,7 @@ export async function POST(req: NextRequest) { // Start title generation in parallel if this is first message (non-streaming) if (actualChatId && !currentChat.title && conversationHistory.length === 0) { - logger.error( - appendCopilotLogContext('Starting title generation for non-streaming response', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }) - ) + reqLogger.info('Starting title generation for non-streaming response') requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse }) .then(async (title) => { if (title) { @@ -633,22 +580,11 @@ export async function POST(req: NextRequest) { updatedAt: new Date(), }) .where(eq(copilotChats.id, actualChatId!)) - logger.error( - appendCopilotLogContext(`Generated and saved title: ${title}`, { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }) - ) + reqLogger.info(`Generated and saved title: ${title}`) } }) .catch((error) => { - logger.error( - appendCopilotLogContext('Title generation failed', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - error - ) + reqLogger.error('Title generation failed', error) }) } @@ -662,17 +598,11 @@ export async function POST(req: NextRequest) { .where(eq(copilotChats.id, actualChatId!)) } - logger.error( - appendCopilotLogContext('Returning non-streaming response', { - requestId: tracker.requestId, - messageId: userMessageIdToUse, - }), - { - duration: tracker.getDuration(), - chatId: actualChatId, - responseLength: responseData.content?.length || 0, - } - ) + reqLogger.info('Returning non-streaming response', { + duration: tracker.getDuration(), + chatId: actualChatId, + responseLength: responseData.content?.length || 0, + }) return NextResponse.json({ success: true, @@ -696,33 +626,25 @@ export async function POST(req: NextRequest) { const duration = tracker.getDuration() if (error instanceof z.ZodError) { - logger.error( - appendCopilotLogContext('Validation error', { - requestId: tracker.requestId, - messageId: pendingChatStreamID ?? undefined, - }), - { + logger + .withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined }) + .error('Validation error', { duration, errors: error.errors, - } - ) + }) return NextResponse.json( { error: 'Invalid request data', details: error.errors }, { status: 400 } ) } - logger.error( - appendCopilotLogContext('Error handling copilot chat', { - requestId: tracker.requestId, - messageId: pendingChatStreamID ?? undefined, - }), - { + logger + .withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined }) + .error('Error handling copilot chat', { duration, error: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : undefined, - } - ) + }) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, @@ -767,16 +689,13 @@ export async function GET(req: NextRequest) { status: meta?.status || 'unknown', } } catch (err) { - logger.warn( - appendCopilotLogContext('Failed to read stream snapshot for chat', { - messageId: chat.conversationId || undefined, - }), - { + logger + .withMetadata({ messageId: chat.conversationId || undefined }) + .warn('Failed to read stream snapshot for chat', { chatId, conversationId: chat.conversationId, error: err instanceof Error ? err.message : String(err), - } - ) + }) } } @@ -795,11 +714,9 @@ export async function GET(req: NextRequest) { ...(streamSnapshot ? { streamSnapshot } : {}), } - logger.error( - appendCopilotLogContext(`Retrieved chat ${chatId}`, { - messageId: chat.conversationId || undefined, - }) - ) + logger + .withMetadata({ messageId: chat.conversationId || undefined }) + .info(`Retrieved chat ${chatId}`) return NextResponse.json({ success: true, chat: transformedChat }) } diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index c442f72ed18..b56d9471817 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { getStreamMeta, readStreamEvents, @@ -36,24 +35,21 @@ export async function GET(request: NextRequest) { const toParam = url.searchParams.get('to') const toEventId = toParam ? Number(toParam) : undefined - logger.error( - appendCopilotLogContext('[Resume] Received resume request', { - messageId: streamId || undefined, - }), - { - streamId: streamId || undefined, - fromEventId, - toEventId, - batchMode, - } - ) + const reqLogger = logger.withMetadata({ messageId: streamId || undefined }) + + reqLogger.info('[Resume] Received resume request', { + streamId: streamId || undefined, + fromEventId, + toEventId, + batchMode, + }) if (!streamId) { return NextResponse.json({ error: 'streamId is required' }, { status: 400 }) } const meta = (await getStreamMeta(streamId)) as StreamMeta | null - logger.error(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), { + reqLogger.info('[Resume] Stream lookup', { streamId, fromEventId, toEventId, @@ -72,7 +68,7 @@ export async function GET(request: NextRequest) { if (batchMode) { const events = await readStreamEvents(streamId, fromEventId) const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events - logger.error(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), { + reqLogger.info('[Resume] Batch response', { streamId, fromEventId, toEventId, @@ -124,14 +120,11 @@ export async function GET(request: NextRequest) { const flushEvents = async () => { const events = await readStreamEvents(streamId, lastEventId) if (events.length > 0) { - logger.error( - appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }), - { - streamId, - fromEventId: lastEventId, - eventCount: events.length, - } - ) + reqLogger.info('[Resume] Flushing events', { + streamId, + fromEventId: lastEventId, + eventCount: events.length, + }) } for (const entry of events) { lastEventId = entry.eventId @@ -178,7 +171,7 @@ export async function GET(request: NextRequest) { } } catch (error) { if (!controllerClosed && !request.signal.aborted) { - logger.warn(appendCopilotLogContext('Stream replay failed', { messageId: streamId }), { + reqLogger.warn('Stream replay failed', { streamId, error: error instanceof Error ? error.message : String(error), }) diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 5e51f4aa4c9..6accb899a1a 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -12,7 +12,6 @@ import { createSSEStream, SSE_RESPONSE_HEADERS, } from '@/lib/copilot/chat-streaming' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents' import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers' @@ -112,27 +111,22 @@ export async function POST(req: NextRequest) { const userMessageId = providedMessageId || crypto.randomUUID() userMessageIdForLogs = userMessageId + const reqLogger = logger.withMetadata({ + requestId: tracker.requestId, + messageId: userMessageId, + }) - logger.error( - appendCopilotLogContext('Received mothership chat start request', { - requestId: tracker.requestId, - messageId: userMessageId, - }), - { - workspaceId, - chatId, - createNewChat, - hasContexts: Array.isArray(contexts) && contexts.length > 0, - contextsCount: Array.isArray(contexts) ? contexts.length : 0, - hasResourceAttachments: - Array.isArray(resourceAttachments) && resourceAttachments.length > 0, - resourceAttachmentCount: Array.isArray(resourceAttachments) - ? resourceAttachments.length - : 0, - hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0, - fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0, - } - ) + reqLogger.info('Received mothership chat start request', { + workspaceId, + chatId, + createNewChat, + hasContexts: Array.isArray(contexts) && contexts.length > 0, + contextsCount: Array.isArray(contexts) ? contexts.length : 0, + hasResourceAttachments: Array.isArray(resourceAttachments) && resourceAttachments.length > 0, + resourceAttachmentCount: Array.isArray(resourceAttachments) ? resourceAttachments.length : 0, + hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0, + fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0, + }) try { await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId) @@ -174,13 +168,7 @@ export async function POST(req: NextRequest) { actualChatId ) } catch (e) { - logger.error( - appendCopilotLogContext('Failed to process contexts', { - requestId: tracker.requestId, - messageId: userMessageId, - }), - e - ) + reqLogger.error('Failed to process contexts', e) } } @@ -205,13 +193,7 @@ export async function POST(req: NextRequest) { if (result.status === 'fulfilled' && result.value) { agentContexts.push(result.value) } else if (result.status === 'rejected') { - logger.error( - appendCopilotLogContext('Failed to resolve resource attachment', { - requestId: tracker.requestId, - messageId: userMessageId, - }), - result.reason - ) + reqLogger.error('Failed to resolve resource attachment', result.reason) } } } @@ -399,16 +381,10 @@ export async function POST(req: NextRequest) { }) } } catch (error) { - logger.error( - appendCopilotLogContext('Failed to persist chat messages', { - requestId: tracker.requestId, - messageId: userMessageId, - }), - { - chatId: actualChatId, - error: error instanceof Error ? error.message : 'Unknown error', - } - ) + reqLogger.error('Failed to persist chat messages', { + chatId: actualChatId, + error: error instanceof Error ? error.message : 'Unknown error', + }) } }, }, @@ -423,15 +399,11 @@ export async function POST(req: NextRequest) { ) } - logger.error( - appendCopilotLogContext('Error handling mothership chat', { - requestId: tracker.requestId, - messageId: userMessageIdForLogs, - }), - { + logger + .withMetadata({ requestId: tracker.requestId, messageId: userMessageIdForLogs }) + .error('Error handling mothership chat', { error: error instanceof Error ? error.message : 'Unknown error', - } - ) + }) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index b51b24a4ace..e41a7c713d6 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -5,7 +5,6 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer' import { authenticateCopilotRequestSessionOnly, @@ -63,16 +62,13 @@ export async function GET( status: meta?.status || 'unknown', } } catch (error) { - logger.warn( - appendCopilotLogContext('Failed to read stream snapshot for mothership chat', { - messageId: chat.conversationId || undefined, - }), - { + logger + .withMetadata({ messageId: chat.conversationId || undefined }) + .warn('Failed to read stream snapshot for mothership chat', { chatId, conversationId: chat.conversationId, error: error instanceof Error ? error.message : String(error), - } - ) + }) } } diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index b0fc3a82d08..0570a808e45 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -4,7 +4,6 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { createRunSegment } from '@/lib/copilot/async-runs/repository' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' import { @@ -53,6 +52,7 @@ export async function POST(req: NextRequest) { const effectiveChatId = chatId || crypto.randomUUID() messageId = crypto.randomUUID() + const reqLogger = logger.withMetadata({ messageId }) const [workspaceContext, integrationTools, userPermission] = await Promise.all([ generateWorkspaceContext(workspaceId, userId), buildIntegrationToolSchemas(userId, messageId), @@ -96,7 +96,7 @@ export async function POST(req: NextRequest) { }) if (!result.success) { - logger.error(appendCopilotLogContext('Mothership execute failed', { messageId }), { + reqLogger.error('Mothership execute failed', { error: result.error, errors: result.errors, }) @@ -135,7 +135,7 @@ export async function POST(req: NextRequest) { ) } - logger.error(appendCopilotLogContext('Mothership execute error', { messageId }), { + logger.withMetadata({ messageId }).error('Mothership execute error', { error: error instanceof Error ? error.message : 'Unknown error', }) diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index dafb1baf0e4..09a5a70f3e1 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createRunSegment } from '@/lib/copilot/async-runs/repository' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' @@ -84,17 +83,15 @@ export async function POST(req: NextRequest) { const chatId = parsed.chatId || crypto.randomUUID() messageId = crypto.randomUUID() - logger.error( - appendCopilotLogContext('Received headless copilot chat start request', { messageId }), - { - workflowId: resolved.workflowId, - workflowName: parsed.workflowName, - chatId, - mode: transportMode, - autoExecuteTools: parsed.autoExecuteTools, - timeout: parsed.timeout, - } - ) + const reqLogger = logger.withMetadata({ messageId }) + reqLogger.info('Received headless copilot chat start request', { + workflowId: resolved.workflowId, + workflowName: parsed.workflowName, + chatId, + mode: transportMode, + autoExecuteTools: parsed.autoExecuteTools, + timeout: parsed.timeout, + }) const requestPayload = { message: parsed.message, workflowId: resolved.workflowId, @@ -144,7 +141,7 @@ export async function POST(req: NextRequest) { ) } - logger.error(appendCopilotLogContext('Headless copilot request failed', { messageId }), { + logger.withMetadata({ messageId }).error('Headless copilot request failed', { error: error instanceof Error ? error.message : String(error), }) return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 355ae6ddf06..a800994c004 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -82,14 +82,16 @@ vi.mock('@/background/workflow-execution', () => ({ executeWorkflowJob: vi.fn(), })) -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ +vi.mock('@sim/logger', () => { + const createMockLogger = (): Record => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - }), -})) + withMetadata: vi.fn(() => createMockLogger()), + }) + return { createLogger: vi.fn(() => createMockLogger()) } +}) vi.mock('uuid', () => ({ validate: vi.fn().mockReturnValue(true), diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index df3fc41d434..86522989004 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -187,6 +187,13 @@ type AsyncExecutionParams = { async function handleAsyncExecution(params: AsyncExecutionParams): Promise { const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } = params + const asyncLogger = logger.withMetadata({ + requestId, + workflowId, + workspaceId, + userId, + executionId, + }) const correlation = { executionId, @@ -233,10 +240,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise { const requestId = generateRequestId() const { id: workflowId } = await params + let reqLogger = logger.withMetadata({ requestId, workflowId }) const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER)) const callChainError = validateCallChain(incomingCallChain) if (callChainError) { - logger.warn(`[${requestId}] Call chain rejected for workflow ${workflowId}: ${callChainError}`) + reqLogger.warn(`Call chain rejected: ${callChainError}`) return NextResponse.json({ error: callChainError }, { status: 409 }) } const callChain = buildNextCallChain(incomingCallChain, workflowId) @@ -414,12 +419,12 @@ async function handleExecutePost( body = JSON.parse(text) } } catch (error) { - logger.warn(`[${requestId}] Failed to parse request body, using defaults`) + reqLogger.warn('Failed to parse request body, using defaults') } const validation = ExecuteWorkflowSchema.safeParse(body) if (!validation.success) { - logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors) + reqLogger.warn('Invalid request body:', validation.error.errors) return NextResponse.json( { error: 'Invalid request body', @@ -589,9 +594,10 @@ async function handleExecutePost( ) } - logger.info(`[${requestId}] Starting server-side execution`, { - workflowId, - userId, + const executionId = uuidv4() + reqLogger = reqLogger.withMetadata({ userId, executionId }) + + reqLogger.info('Starting server-side execution', { hasInput: !!input, triggerType, authType: auth.authType, @@ -600,8 +606,6 @@ async function handleExecutePost( enableSSE, isAsyncMode, }) - - const executionId = uuidv4() let loggingTriggerType: CoreTriggerType = 'manual' if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) { loggingTriggerType = triggerType as CoreTriggerType @@ -657,10 +661,11 @@ async function handleExecutePost( const workflow = preprocessResult.workflowRecord! if (!workflow.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) + reqLogger.error('Workflow has no workspaceId') return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 }) } const workspaceId = workflow.workspaceId + reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId }) if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workspaceId) { return NextResponse.json( @@ -669,11 +674,7 @@ async function handleExecutePost( ) } - logger.info(`[${requestId}] Preprocessing passed`, { - workflowId, - actorUserId, - workspaceId, - }) + reqLogger.info('Preprocessing passed') if (isAsyncMode) { return handleAsyncExecution({ @@ -744,7 +745,7 @@ async function handleExecutePost( ) } } catch (fileError) { - logger.error(`[${requestId}] Failed to process input file fields:`, fileError) + reqLogger.error('Failed to process input file fields:', fileError) await loggingSession.safeStart({ userId: actorUserId, @@ -772,7 +773,7 @@ async function handleExecutePost( sanitizedWorkflowStateOverride || cachedWorkflowData || undefined if (!enableSSE) { - logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`) + reqLogger.info('Using non-SSE execution (direct JSON response)') const metadata: ExecutionMetadata = { requestId, executionId, @@ -866,7 +867,7 @@ async function handleExecutePost( const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.error(`[${requestId}] Queued non-SSE execution failed: ${errorMessage}`) + reqLogger.error(`Queued non-SSE execution failed: ${errorMessage}`) return NextResponse.json( { @@ -908,7 +909,7 @@ async function handleExecutePost( timeoutController.timeoutMs ) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Non-SSE execution timed out`, { + reqLogger.info('Non-SSE execution timed out', { timeoutMs: timeoutController.timeoutMs, }) await loggingSession.markAsFailed(timeoutErrorMessage) @@ -962,7 +963,7 @@ async function handleExecutePost( } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`) + reqLogger.error(`Non-SSE execution failed: ${errorMessage}`) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined @@ -985,7 +986,7 @@ async function handleExecutePost( timeoutController.cleanup() if (executionId) { void cleanupExecutionBase64Cache(executionId).catch((error) => { - logger.error(`[${requestId}] Failed to cleanup base64 cache`, { error }) + reqLogger.error('Failed to cleanup base64 cache', { error }) }) } } @@ -1039,9 +1040,9 @@ async function handleExecutePost( }) } - logger.info(`[${requestId}] Using SSE console log streaming (manual execution)`) + reqLogger.info('Using SSE console log streaming (manual execution)') } else { - logger.info(`[${requestId}] Using streaming API response`) + reqLogger.info('Using streaming API response') const resolvedSelectedOutputs = resolveOutputIds( selectedOutputs, @@ -1135,7 +1136,7 @@ async function handleExecutePost( iterationContext?: IterationContext, childWorkflowContext?: ChildWorkflowContext ) => { - logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType }) + reqLogger.info('onBlockStart called', { blockId, blockName, blockType }) sendEvent({ type: 'block:started', timestamp: new Date().toISOString(), @@ -1184,7 +1185,7 @@ async function handleExecutePost( : {} if (hasError) { - logger.info(`[${requestId}] ✗ onBlockComplete (error) called:`, { + reqLogger.info('onBlockComplete (error) called', { blockId, blockName, blockType, @@ -1219,7 +1220,7 @@ async function handleExecutePost( }, }) } else { - logger.info(`[${requestId}] ✓ onBlockComplete called:`, { + reqLogger.info('onBlockComplete called', { blockId, blockName, blockType, @@ -1284,7 +1285,7 @@ async function handleExecutePost( data: { blockId }, }) } catch (error) { - logger.error(`[${requestId}] Error streaming block content:`, error) + reqLogger.error('Error streaming block content:', error) } finally { try { await reader.cancel().catch(() => {}) @@ -1360,9 +1361,7 @@ async function handleExecutePost( if (result.status === 'paused') { if (!result.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) + reqLogger.error('Missing snapshot seed for paused execution') await loggingSession.markAsFailed('Missing snapshot seed for paused execution') } else { try { @@ -1374,8 +1373,7 @@ async function handleExecutePost( executorUserId: result.metadata?.userId, }) } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, + reqLogger.error('Failed to persist pause result', { error: pauseError instanceof Error ? pauseError.message : String(pauseError), }) await loggingSession.markAsFailed( @@ -1390,7 +1388,7 @@ async function handleExecutePost( if (result.status === 'cancelled') { if (timeoutController.isTimedOut() && timeoutController.timeoutMs) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Workflow execution timed out`, { + reqLogger.info('Workflow execution timed out', { timeoutMs: timeoutController.timeoutMs, }) @@ -1408,7 +1406,7 @@ async function handleExecutePost( }) finalMetaStatus = 'error' } else { - logger.info(`[${requestId}] Workflow execution was cancelled`) + reqLogger.info('Workflow execution was cancelled') sendEvent({ type: 'execution:cancelled', @@ -1452,7 +1450,7 @@ async function handleExecutePost( ? error.message : 'Unknown error' - logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`, { isTimeout }) + reqLogger.error(`SSE execution failed: ${errorMessage}`, { isTimeout }) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined @@ -1475,7 +1473,7 @@ async function handleExecutePost( try { await eventWriter.close() } catch (closeError) { - logger.warn(`[${requestId}] Failed to close event writer`, { + reqLogger.warn('Failed to close event writer', { error: closeError instanceof Error ? closeError.message : String(closeError), }) } @@ -1496,7 +1494,7 @@ async function handleExecutePost( }, cancel() { isStreamClosed = true - logger.info(`[${requestId}] Client disconnected from SSE stream`) + reqLogger.info('Client disconnected from SSE stream') }, }) @@ -1518,7 +1516,7 @@ async function handleExecutePost( ) } - logger.error(`[${requestId}] Failed to start workflow execution:`, error) + reqLogger.error('Failed to start workflow execution:', error) return NextResponse.json( { error: error.message || 'Failed to start workflow execution' }, { status: 500 } diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 5680ee4e3cf..5044eab5639 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { redactApiKeys } from '@/lib/core/security/redaction' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -49,12 +49,22 @@ import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' const logger = createLogger('BlockExecutor') export class BlockExecutor { + private execLogger: Logger + constructor( private blockHandlers: BlockHandler[], private resolver: VariableResolver, private contextExtensions: ContextExtensions, private state: BlockStateWriter - ) {} + ) { + this.execLogger = logger.withMetadata({ + workflowId: this.contextExtensions.metadata?.workflowId, + workspaceId: this.contextExtensions.workspaceId, + executionId: this.contextExtensions.executionId, + userId: this.contextExtensions.userId, + requestId: this.contextExtensions.metadata?.requestId, + }) + } async execute( ctx: ExecutionContext, @@ -273,7 +283,7 @@ export class BlockExecutor { } } - logger.error( + this.execLogger.error( phase === 'input_resolution' ? 'Failed to resolve block inputs' : 'Block execution failed', { blockId: node.id, @@ -306,7 +316,7 @@ export class BlockExecutor { if (blockLog) { blockLog.errorHandled = true } - logger.info('Block has error port - returning error output instead of throwing', { + this.execLogger.info('Block has error port - returning error output instead of throwing', { blockId: node.id, error: errorMessage, }) @@ -358,7 +368,7 @@ export class BlockExecutor { blockName = `${blockName} (iteration ${loopScope.iteration})` iterationIndex = loopScope.iteration } else { - logger.warn('Loop scope not found for block', { blockId, loopId }) + this.execLogger.warn('Loop scope not found for block', { blockId, loopId }) } } } @@ -462,7 +472,7 @@ export class BlockExecutor { ctx.childWorkflowContext ) } catch (error) { - logger.warn('Block start callback failed', { + this.execLogger.warn('Block start callback failed', { blockId, blockType, error: error instanceof Error ? error.message : String(error), @@ -508,7 +518,7 @@ export class BlockExecutor { ctx.childWorkflowContext ) } catch (error) { - logger.warn('Block completion callback failed', { + this.execLogger.warn('Block completion callback failed', { blockId, blockType, error: error instanceof Error ? error.message : String(error), @@ -633,7 +643,7 @@ export class BlockExecutor { try { await ctx.onStream?.(clientStreamingExec) } catch (error) { - logger.error('Error in onStream callback', { blockId, error }) + this.execLogger.error('Error in onStream callback', { blockId, error }) // Cancel the client stream to release the tee'd buffer await processedClientStream.cancel().catch(() => {}) } @@ -663,7 +673,7 @@ export class BlockExecutor { stream: processedStream, }) } catch (error) { - logger.error('Error in onStream callback', { blockId, error }) + this.execLogger.error('Error in onStream callback', { blockId, error }) await processedStream.cancel().catch(() => {}) } } @@ -687,7 +697,7 @@ export class BlockExecutor { const tail = decoder.decode() if (tail) chunks.push(tail) } catch (error) { - logger.error('Error reading executor stream for block', { blockId, error }) + this.execLogger.error('Error reading executor stream for block', { blockId, error }) } finally { try { await reader.cancel().catch(() => {}) @@ -718,7 +728,10 @@ export class BlockExecutor { } return } catch (error) { - logger.warn('Failed to parse streamed content for response format', { blockId, error }) + this.execLogger.warn('Failed to parse streamed content for response format', { + blockId, + error, + }) } } diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index 2f479125282..a420c5df7dd 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { BlockType } from '@/executor/constants' import type { DAG } from '@/executor/dag/builder' @@ -34,6 +34,7 @@ export class ExecutionEngine { private readonly CANCELLATION_CHECK_INTERVAL_MS = 500 private abortPromise: Promise | null = null private abortResolve: (() => void) | null = null + private execLogger: Logger constructor( private context: ExecutionContext, @@ -43,6 +44,13 @@ export class ExecutionEngine { ) { this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true this.useRedisCancellation = isRedisCancellationEnabled() && !!this.context.executionId + this.execLogger = logger.withMetadata({ + workflowId: this.context.workflowId, + workspaceId: this.context.workspaceId, + executionId: this.context.executionId, + userId: this.context.userId, + requestId: this.context.metadata.requestId, + }) this.initializeAbortHandler() } @@ -88,7 +96,9 @@ export class ExecutionEngine { const cancelled = await isExecutionCancelled(this.context.executionId!) if (cancelled) { this.cancelledFlag = true - logger.info('Execution cancelled via Redis', { executionId: this.context.executionId }) + this.execLogger.info('Execution cancelled via Redis', { + executionId: this.context.executionId, + }) } return cancelled } @@ -169,7 +179,7 @@ export class ExecutionEngine { this.finalizeIncompleteLogs() const errorMessage = normalizeError(error) - logger.error('Execution failed', { error: errorMessage }) + this.execLogger.error('Execution failed', { error: errorMessage }) const executionResult: ExecutionResult = { success: false, @@ -270,7 +280,7 @@ export class ExecutionEngine { private initializeQueue(triggerBlockId?: string): void { if (this.context.runFromBlockContext) { const { startBlockId } = this.context.runFromBlockContext - logger.info('Initializing queue for run-from-block mode', { + this.execLogger.info('Initializing queue for run-from-block mode', { startBlockId, dirtySetSize: this.context.runFromBlockContext.dirtySet.size, }) @@ -282,7 +292,7 @@ export class ExecutionEngine { const remainingEdges = (this.context.metadata as any).remainingEdges if (remainingEdges && Array.isArray(remainingEdges) && remainingEdges.length > 0) { - logger.info('Removing edges from resumed pause blocks', { + this.execLogger.info('Removing edges from resumed pause blocks', { edgeCount: remainingEdges.length, edges: remainingEdges, }) @@ -294,13 +304,13 @@ export class ExecutionEngine { targetNode.incomingEdges.delete(edge.source) if (this.edgeManager.isNodeReady(targetNode)) { - logger.info('Node became ready after edge removal', { nodeId: targetNode.id }) + this.execLogger.info('Node became ready after edge removal', { nodeId: targetNode.id }) this.addToQueue(targetNode.id) } } } - logger.info('Edge removal complete, queued ready nodes', { + this.execLogger.info('Edge removal complete, queued ready nodes', { queueLength: this.readyQueue.length, queuedNodes: this.readyQueue, }) @@ -309,7 +319,7 @@ export class ExecutionEngine { } if (pendingBlocks && pendingBlocks.length > 0) { - logger.info('Initializing queue from pending blocks (resume mode)', { + this.execLogger.info('Initializing queue from pending blocks (resume mode)', { pendingBlocks, allowResumeTriggers: this.allowResumeTriggers, dagNodeCount: this.dag.nodes.size, @@ -319,7 +329,7 @@ export class ExecutionEngine { this.addToQueue(nodeId) } - logger.info('Pending blocks queued', { + this.execLogger.info('Pending blocks queued', { queueLength: this.readyQueue.length, queuedNodes: this.readyQueue, }) @@ -341,7 +351,7 @@ export class ExecutionEngine { if (startNode) { this.addToQueue(startNode.id) } else { - logger.warn('No start node found in DAG') + this.execLogger.warn('No start node found in DAG') } } @@ -373,7 +383,7 @@ export class ExecutionEngine { } } catch (error) { const errorMessage = normalizeError(error) - logger.error('Node execution failed', { nodeId, error: errorMessage }) + this.execLogger.error('Node execution failed', { nodeId, error: errorMessage }) throw error } } @@ -385,7 +395,7 @@ export class ExecutionEngine { ): Promise { const node = this.dag.nodes.get(nodeId) if (!node) { - logger.error('Node not found during completion', { nodeId }) + this.execLogger.error('Node not found during completion', { nodeId }) return } @@ -409,7 +419,7 @@ export class ExecutionEngine { // shouldContinue: true means more iterations, shouldExit: true means loop is done const shouldContinueLoop = output.shouldContinue === true if (!shouldContinueLoop) { - logger.info('Stopping execution after target block', { nodeId }) + this.execLogger.info('Stopping execution after target block', { nodeId }) this.stoppedEarlyFlag = true return } @@ -417,7 +427,7 @@ export class ExecutionEngine { const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false) - logger.info('Processing outgoing edges', { + this.execLogger.info('Processing outgoing edges', { nodeId, outgoingEdgesCount: node.outgoingEdges.size, outgoingEdges: Array.from(node.outgoingEdges.entries()).map(([id, e]) => ({ @@ -435,7 +445,7 @@ export class ExecutionEngine { if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) { const dynamicNodes = this.context.pendingDynamicNodes this.context.pendingDynamicNodes = [] - logger.info('Adding dynamically expanded parallel nodes', { dynamicNodes }) + this.execLogger.info('Adding dynamically expanded parallel nodes', { dynamicNodes }) this.addMultipleToQueue(dynamicNodes) } } @@ -482,7 +492,7 @@ export class ExecutionEngine { } return parsedSnapshot.state } catch (error) { - logger.warn('Failed to serialize execution state', { + this.execLogger.warn('Failed to serialize execution state', { error: error instanceof Error ? error.message : String(error), }) return undefined diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 67d3b4c24b8..8e3a8c8c8c9 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, type Logger } from '@sim/logger' import { StartBlockPath } from '@/lib/workflows/triggers/triggers' import type { DAG } from '@/executor/dag/builder' import { DAGBuilder } from '@/executor/dag/builder' @@ -52,6 +52,7 @@ export class DAGExecutor { private workflowVariables: Record private contextExtensions: ContextExtensions private dagBuilder: DAGBuilder + private execLogger: Logger constructor(options: DAGExecutorOptions) { this.workflow = options.workflow @@ -60,6 +61,13 @@ export class DAGExecutor { this.workflowVariables = options.workflowVariables ?? {} this.contextExtensions = options.contextExtensions ?? {} this.dagBuilder = new DAGBuilder() + this.execLogger = logger.withMetadata({ + workflowId: this.contextExtensions.metadata?.workflowId, + workspaceId: this.contextExtensions.workspaceId, + executionId: this.contextExtensions.executionId, + userId: this.contextExtensions.userId, + requestId: this.contextExtensions.metadata?.requestId, + }) } async execute(workflowId: string, triggerBlockId?: string): Promise { @@ -79,7 +87,9 @@ export class DAGExecutor { _pendingBlocks: string[], context: ExecutionContext ): Promise { - logger.warn('Debug mode (continueExecution) is not yet implemented in the refactored executor') + this.execLogger.warn( + 'Debug mode (continueExecution) is not yet implemented in the refactored executor' + ) return { success: false, output: {}, @@ -163,7 +173,7 @@ export class DAGExecutor { parallelExecutions: filteredParallelExecutions, } - logger.info('Executing from block', { + this.execLogger.info('Executing from block', { workflowId, startBlockId, effectiveStartBlockId, @@ -247,7 +257,7 @@ export class DAGExecutor { if (overrides?.runFromBlockContext) { const { dirtySet } = overrides.runFromBlockContext executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id))) - logger.info('Cleared executed status for dirty blocks', { + this.execLogger.info('Cleared executed status for dirty blocks', { dirtySetSize: dirtySet.size, remainingExecutedBlocks: executedBlocks.size, }) @@ -332,7 +342,7 @@ export class DAGExecutor { if (this.contextExtensions.resumeFromSnapshot) { context.metadata.resumeFromSnapshot = true - logger.info('Resume from snapshot enabled', { + this.execLogger.info('Resume from snapshot enabled', { resumePendingQueue: this.contextExtensions.resumePendingQueue, remainingEdges: this.contextExtensions.remainingEdges, triggerBlockId, @@ -341,14 +351,14 @@ export class DAGExecutor { if (this.contextExtensions.remainingEdges) { ;(context.metadata as any).remainingEdges = this.contextExtensions.remainingEdges - logger.info('Set remaining edges for resume', { + this.execLogger.info('Set remaining edges for resume', { edgeCount: this.contextExtensions.remainingEdges.length, }) } if (this.contextExtensions.resumePendingQueue?.length) { context.metadata.pendingBlocks = [...this.contextExtensions.resumePendingQueue] - logger.info('Set pending blocks from resume queue', { + this.execLogger.info('Set pending blocks from resume queue', { pendingBlocks: context.metadata.pendingBlocks, skipStarterBlockInit: true, }) @@ -409,7 +419,7 @@ export class DAGExecutor { if (triggerBlockId) { const triggerBlock = this.workflow.blocks.find((b) => b.id === triggerBlockId) if (!triggerBlock) { - logger.error('Specified trigger block not found in workflow', { + this.execLogger.error('Specified trigger block not found in workflow', { triggerBlockId, }) throw new Error(`Trigger block not found: ${triggerBlockId}`) @@ -431,7 +441,7 @@ export class DAGExecutor { }) if (!startResolution?.block) { - logger.warn('No start block found in workflow') + this.execLogger.warn('No start block found in workflow') return } } diff --git a/apps/sim/lib/copilot/chat-payload.test.ts b/apps/sim/lib/copilot/chat-payload.test.ts index 1447e32882f..0c7b187e7fd 100644 --- a/apps/sim/lib/copilot/chat-payload.test.ts +++ b/apps/sim/lib/copilot/chat-payload.test.ts @@ -3,13 +3,15 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ +vi.mock('@sim/logger', () => { + const createMockLogger = (): Record => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), - })), -})) + withMetadata: vi.fn(() => createMockLogger()), + }) + return { createLogger: vi.fn(() => createMockLogger()) } +}) vi.mock('@/lib/billing/core/subscription', () => ({ getUserSubscriptionState: vi.fn(), diff --git a/apps/sim/lib/copilot/chat-payload.ts b/apps/sim/lib/copilot/chat-payload.ts index 81731cf9dff..69b1d342f17 100644 --- a/apps/sim/lib/copilot/chat-payload.ts +++ b/apps/sim/lib/copilot/chat-payload.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { getUserSubscriptionState } from '@/lib/billing/core/subscription' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions' import { isHosted } from '@/lib/core/config/feature-flags' import { createMcpToolId } from '@/lib/mcp/utils' @@ -50,6 +49,7 @@ export async function buildIntegrationToolSchemas( userId: string, messageId?: string ): Promise { + const reqLogger = logger.withMetadata({ messageId }) const integrationTools: ToolSchema[] = [] try { const { createUserToolSchema } = await import('@/tools/params') @@ -60,15 +60,10 @@ export async function buildIntegrationToolSchemas( const subscriptionState = await getUserSubscriptionState(userId) shouldAppendEmailTagline = subscriptionState.isFree } catch (error) { - logger.warn( - appendCopilotLogContext('Failed to load subscription state for copilot tool descriptions', { - messageId, - }), - { - userId, - error: error instanceof Error ? error.message : String(error), - } - ) + reqLogger.warn('Failed to load subscription state for copilot tool descriptions', { + userId, + error: error instanceof Error ? error.message : String(error), + }) } for (const [toolId, toolConfig] of Object.entries(latestTools)) { @@ -92,17 +87,14 @@ export async function buildIntegrationToolSchemas( }), }) } catch (toolError) { - logger.warn( - appendCopilotLogContext('Failed to build schema for tool, skipping', { messageId }), - { - toolId, - error: toolError instanceof Error ? toolError.message : String(toolError), - } - ) + reqLogger.warn('Failed to build schema for tool, skipping', { + toolId, + error: toolError instanceof Error ? toolError.message : String(toolError), + }) } } } catch (error) { - logger.warn(appendCopilotLogContext('Failed to build tool schemas', { messageId }), { + reqLogger.warn('Failed to build tool schemas', { error: error instanceof Error ? error.message : String(error), }) } @@ -182,6 +174,8 @@ export async function buildCopilotRequestPayload( let integrationTools: ToolSchema[] = [] + const payloadLogger = logger.withMetadata({ messageId: userMessageId }) + if (effectiveMode === 'build') { integrationTools = await buildIntegrationToolSchemas(userId, userMessageId) @@ -201,23 +195,13 @@ export async function buildCopilotRequestPayload( }) } if (mcpTools.length > 0) { - logger.error( - appendCopilotLogContext('Added MCP tools to copilot payload', { - messageId: userMessageId, - }), - { count: mcpTools.length } - ) + payloadLogger.info('Added MCP tools to copilot payload', { count: mcpTools.length }) } } } catch (error) { - logger.warn( - appendCopilotLogContext('Failed to discover MCP tools for copilot', { - messageId: userMessageId, - }), - { - error: error instanceof Error ? error.message : String(error), - } - ) + payloadLogger.warn('Failed to discover MCP tools for copilot', { + error: error instanceof Error ? error.message : String(error), + }) } } } diff --git a/apps/sim/lib/copilot/chat-streaming.ts b/apps/sim/lib/copilot/chat-streaming.ts index 76ae305a9f8..5779d20f65f 100644 --- a/apps/sim/lib/copilot/chat-streaming.ts +++ b/apps/sim/lib/copilot/chat-streaming.ts @@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import type { OrchestrateStreamOptions } from '@/lib/copilot/orchestrator' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { @@ -229,20 +228,17 @@ export async function requestChatTitle(params: { const payload = await response.json().catch(() => ({})) if (!response.ok) { - logger.warn( - appendCopilotLogContext('Failed to generate chat title via copilot backend', { messageId }), - { - status: response.status, - error: payload, - } - ) + logger.withMetadata({ messageId }).warn('Failed to generate chat title via copilot backend', { + status: response.status, + error: payload, + }) return null } const title = typeof payload?.title === 'string' ? payload.title.trim() : '' return title || null } catch (error) { - logger.error(appendCopilotLogContext('Error generating chat title', { messageId }), error) + logger.withMetadata({ messageId }).error('Error generating chat title', error) return null } } @@ -285,6 +281,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS } = params const messageId = typeof requestPayload.messageId === 'string' ? requestPayload.messageId : streamId + const reqLogger = logger.withMetadata({ requestId, messageId }) let eventWriter: ReturnType | null = null let clientDisconnected = false @@ -306,17 +303,11 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS if (!clientDisconnectedController.signal.aborted) { clientDisconnectedController.abort() } - logger.info( - appendCopilotLogContext('Client disconnected from live SSE stream', { - requestId, - messageId, - }), - { - streamId, - runId, - reason, - } - ) + reqLogger.info('Client disconnected from live SSE stream', { + streamId, + runId, + reason, + }) } await resetStreamBuffer(streamId) @@ -334,15 +325,9 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS provider: (requestPayload.provider as string | undefined) || null, requestContext: { requestId }, }).catch((error) => { - logger.warn( - appendCopilotLogContext('Failed to create copilot run segment', { - requestId, - messageId, - }), - { - error: error instanceof Error ? error.message : String(error), - } - ) + reqLogger.warn('Failed to create copilot run segment', { + error: error instanceof Error ? error.message : String(error), + }) }) } eventWriter = createStreamEventWriter(streamId) @@ -362,16 +347,10 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS await redis.del(getStreamAbortKey(streamId)) } } catch (error) { - logger.warn( - appendCopilotLogContext('Failed to poll distributed stream abort', { - requestId, - messageId, - }), - { - streamId, - error: error instanceof Error ? error.message : String(error), - } - ) + reqLogger.warn('Failed to poll distributed stream abort', { + streamId, + error: error instanceof Error ? error.message : String(error), + }) } })() }, STREAM_ABORT_POLL_MS) @@ -388,14 +367,11 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS await eventWriter.flush() } } catch (error) { - logger.error( - appendCopilotLogContext('Failed to persist stream event', { requestId, messageId }), - { - eventType: event.type, - eventId, - error: error instanceof Error ? error.message : String(error), - } - ) + reqLogger.error('Failed to persist stream event', { + eventType: event.type, + eventId, + error: error instanceof Error ? error.message : String(error), + }) // Keep the live SSE stream going even if durable buffering hiccups. } @@ -414,7 +390,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS try { await pushEvent(event) } catch (error) { - logger.error(appendCopilotLogContext('Failed to push event', { requestId, messageId }), { + reqLogger.error('Failed to push event', { eventType: event.type, error: error instanceof Error ? error.message : String(error), }) @@ -437,10 +413,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS } }) .catch((error) => { - logger.error( - appendCopilotLogContext('Title generation failed', { requestId, messageId }), - error - ) + reqLogger.error('Title generation failed', error) }) } @@ -467,9 +440,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS }) if (abortController.signal.aborted) { - logger.error( - appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId }) - ) + reqLogger.info('Stream aborted by explicit stop') await eventWriter.close().catch(() => {}) await setStreamMeta(streamId, { status: 'cancelled', userId, executionId, runId }) await updateRunStatus(runId, 'cancelled', { completedAt: new Date() }).catch(() => {}) @@ -483,23 +454,14 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS 'An unexpected error occurred while processing the response.' if (clientDisconnected) { - logger.error( - appendCopilotLogContext('Stream failed after client disconnect', { - requestId, - messageId, - }), - { - error: errorMessage, - } - ) + reqLogger.info('Stream failed after client disconnect', { + error: errorMessage, + }) } - logger.error( - appendCopilotLogContext('Orchestration returned failure', { requestId, messageId }), - { - error: errorMessage, - } - ) + reqLogger.error('Orchestration returned failure', { + error: errorMessage, + }) await pushEventBestEffort({ type: 'error', error: errorMessage, @@ -526,42 +488,25 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS await setStreamMeta(streamId, { status: 'complete', userId, executionId, runId }) await updateRunStatus(runId, 'complete', { completedAt: new Date() }).catch(() => {}) if (clientDisconnected) { - logger.info( - appendCopilotLogContext('Orchestration completed after client disconnect', { - requestId, - messageId, - }), - { - streamId, - runId, - } - ) + reqLogger.info('Orchestration completed after client disconnect', { + streamId, + runId, + }) } } catch (error) { if (abortController.signal.aborted) { - logger.error( - appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId }) - ) + reqLogger.info('Stream aborted by explicit stop') await eventWriter.close().catch(() => {}) await setStreamMeta(streamId, { status: 'cancelled', userId, executionId, runId }) await updateRunStatus(runId, 'cancelled', { completedAt: new Date() }).catch(() => {}) return } if (clientDisconnected) { - logger.error( - appendCopilotLogContext('Stream errored after client disconnect', { - requestId, - messageId, - }), - { - error: error instanceof Error ? error.message : 'Stream error', - } - ) + reqLogger.info('Stream errored after client disconnect', { + error: error instanceof Error ? error.message : 'Stream error', + }) } - logger.error( - appendCopilotLogContext('Orchestration error', { requestId, messageId }), - error - ) + reqLogger.error('Orchestration error', error) const errorMessage = error instanceof Error ? error.message : 'Stream error' await pushEventBestEffort({ type: 'error', @@ -583,7 +528,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS error: errorMessage, }).catch(() => {}) } finally { - logger.info(appendCopilotLogContext('Closing live SSE stream', { requestId, messageId }), { + reqLogger.info('Closing live SSE stream', { streamId, runId, clientDisconnected, @@ -611,16 +556,10 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS } }, cancel() { - logger.info( - appendCopilotLogContext('ReadableStream cancel received from client', { - requestId, - messageId, - }), - { - streamId, - runId, - } - ) + reqLogger.info('ReadableStream cancel received from client', { + streamId, + runId, + }) if (!clientDisconnected) { clientDisconnected = true if (!clientDisconnectedController.signal.aborted) { diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts index d07553ca645..3b03aa8c982 100644 --- a/apps/sim/lib/copilot/orchestrator/index.ts +++ b/apps/sim/lib/copilot/orchestrator/index.ts @@ -14,7 +14,6 @@ import { updateRunStatus, } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { isToolAvailableOnSimSide, prepareExecutionContext, @@ -128,15 +127,11 @@ export async function orchestrateCopilotStream( messageId, }) const continuationWorkerId = `sim-resume:${crypto.randomUUID()}` - const withLogContext = (message: string) => - appendCopilotLogContext(message, { - requestId: context.requestId, - messageId, - }) + const reqLogger = logger.withMetadata({ requestId: context.requestId, messageId }) let claimedToolCallIds: string[] = [] let claimedByWorkerId: string | null = null - logger.error(withLogContext('Starting copilot orchestration'), { + reqLogger.info('Starting copilot orchestration', { goRoute, workflowId, workspaceId, @@ -155,7 +150,7 @@ export async function orchestrateCopilotStream( for (;;) { context.streamComplete = false - logger.error(withLogContext('Starting orchestration loop iteration'), { + reqLogger.info('Starting orchestration loop iteration', { route, hasPendingAsyncContinuation: Boolean(context.awaitingAsyncContinuation), claimedToolCallCount: claimedToolCallIds.length, @@ -168,7 +163,7 @@ export async function orchestrateCopilotStream( const d = (event.data ?? {}) as Record const response = (d.response ?? {}) as Record if (response.async_pause) { - logger.error(withLogContext('Detected async pause from copilot backend'), { + reqLogger.info('Detected async pause from copilot backend', { route, checkpointId: typeof (response.async_pause as Record)?.checkpointId === @@ -201,7 +196,7 @@ export async function orchestrateCopilotStream( loopOptions ) - logger.error(withLogContext('Completed orchestration loop iteration'), { + reqLogger.info('Completed orchestration loop iteration', { route, streamComplete: context.streamComplete, wasAborted: context.wasAborted, @@ -210,7 +205,7 @@ export async function orchestrateCopilotStream( }) if (claimedToolCallIds.length > 0) { - logger.error(withLogContext('Marking async tool calls as delivered'), { + reqLogger.info('Marking async tool calls as delivered', { toolCallIds: claimedToolCallIds, }) await Promise.all( @@ -223,7 +218,7 @@ export async function orchestrateCopilotStream( } if (options.abortSignal?.aborted || context.wasAborted) { - logger.error(withLogContext('Stopping orchestration because request was aborted'), { + reqLogger.info('Stopping orchestration because request was aborted', { pendingToolCallCount: Array.from(context.toolCalls.values()).filter( (toolCall) => toolCall.status === 'pending' || toolCall.status === 'executing' ).length, @@ -241,13 +236,13 @@ export async function orchestrateCopilotStream( const continuation = context.awaitingAsyncContinuation if (!continuation) { - logger.error(withLogContext('No async continuation pending; finishing orchestration')) + reqLogger.info('No async continuation pending; finishing orchestration') break } let resumeReady = false let resumeRetries = 0 - logger.error(withLogContext('Processing async continuation'), { + reqLogger.info('Processing async continuation', { checkpointId: continuation.checkpointId, runId: continuation.runId, pendingToolCallIds: continuation.pendingToolCallIds, @@ -267,26 +262,19 @@ export async function orchestrateCopilotStream( if (localPendingPromise) { localPendingPromises.push(localPendingPromise) - logger.info( - withLogContext( - 'Waiting for local async tool completion before retrying resume claim' - ), - { - toolCallId, - runId: continuation.runId, - workerId: resumeWorkerId, - } - ) + reqLogger.info('Waiting for local async tool completion before retrying resume claim', { + toolCallId, + runId: continuation.runId, + workerId: resumeWorkerId, + }) continue } if (durableRow && isTerminalAsyncStatus(durableRow.status)) { if (durableRow.claimedBy && durableRow.claimedBy !== resumeWorkerId) { missingToolCallIds.push(toolCallId) - logger.warn( - withLogContext( - 'Async tool continuation is waiting on a claim held by another worker' - ), + reqLogger.warn( + 'Async tool continuation is waiting on a claim held by another worker', { toolCallId, runId: continuation.runId, @@ -312,15 +300,12 @@ export async function orchestrateCopilotStream( isTerminalToolCallStatus(toolState.status) && !isToolAvailableOnSimSide(toolState.name) ) { - logger.info( - withLogContext('Including Go-handled tool in resume payload (no Sim-side row)'), - { - toolCallId, - toolName: toolState.name, - status: toolState.status, - runId: continuation.runId, - } - ) + reqLogger.info('Including Go-handled tool in resume payload (no Sim-side row)', { + toolCallId, + toolName: toolState.name, + status: toolState.status, + runId: continuation.runId, + }) readyTools.push({ toolCallId, toolState, @@ -330,7 +315,7 @@ export async function orchestrateCopilotStream( continue } - logger.warn(withLogContext('Skipping already-claimed or missing async tool resume'), { + reqLogger.warn('Skipping already-claimed or missing async tool resume', { toolCallId, runId: continuation.runId, durableStatus: durableRow?.status, @@ -340,13 +325,10 @@ export async function orchestrateCopilotStream( } if (localPendingPromises.length > 0) { - logger.info( - withLogContext('Waiting for local pending async tools before resuming continuation'), - { - checkpointId: continuation.checkpointId, - pendingPromiseCount: localPendingPromises.length, - } - ) + reqLogger.info('Waiting for local pending async tools before resuming continuation', { + checkpointId: continuation.checkpointId, + pendingPromiseCount: localPendingPromises.length, + }) await Promise.allSettled(localPendingPromises) continue } @@ -354,23 +336,18 @@ export async function orchestrateCopilotStream( if (missingToolCallIds.length > 0) { if (resumeRetries < 3) { resumeRetries++ - logger.info( - withLogContext('Retrying async resume after some tool calls were not yet ready'), - { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - retry: resumeRetries, - missingToolCallIds, - } - ) + reqLogger.info('Retrying async resume after some tool calls were not yet ready', { + checkpointId: continuation.checkpointId, + runId: continuation.runId, + workerId: resumeWorkerId, + retry: resumeRetries, + missingToolCallIds, + }) await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries)) continue } - logger.error( - withLogContext( - 'Async continuation failed because pending tool calls never became ready' - ), + reqLogger.error( + 'Async continuation failed because pending tool calls never became ready', { checkpointId: continuation.checkpointId, runId: continuation.runId, @@ -385,26 +362,20 @@ export async function orchestrateCopilotStream( if (readyTools.length === 0) { if (resumeRetries < 3 && continuation.pendingToolCallIds.length > 0) { resumeRetries++ - logger.info( - withLogContext('Retrying async resume because no tool calls were ready yet'), - { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - retry: resumeRetries, - } - ) + reqLogger.info('Retrying async resume because no tool calls were ready yet', { + checkpointId: continuation.checkpointId, + runId: continuation.runId, + workerId: resumeWorkerId, + retry: resumeRetries, + }) await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries)) continue } - logger.error( - withLogContext('Async continuation failed because no tool calls were ready'), - { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - requestedToolCallIds: continuation.pendingToolCallIds, - } - ) + reqLogger.error('Async continuation failed because no tool calls were ready', { + checkpointId: continuation.checkpointId, + runId: continuation.runId, + requestedToolCallIds: continuation.pendingToolCallIds, + }) throw new Error('Failed to resume async tool continuation: no tool calls were ready') } @@ -425,16 +396,13 @@ export async function orchestrateCopilotStream( if (claimFailures.length > 0) { if (newlyClaimedToolCallIds.length > 0) { - logger.info( - withLogContext('Releasing async tool claims after claim contention during resume'), - { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - newlyClaimedToolCallIds, - claimFailures, - } - ) + reqLogger.info('Releasing async tool claims after claim contention during resume', { + checkpointId: continuation.checkpointId, + runId: continuation.runId, + workerId: resumeWorkerId, + newlyClaimedToolCallIds, + claimFailures, + }) await Promise.all( newlyClaimedToolCallIds.map((toolCallId) => releaseCompletedAsyncToolClaim(toolCallId, resumeWorkerId).catch(() => null) @@ -443,7 +411,7 @@ export async function orchestrateCopilotStream( } if (resumeRetries < 3) { resumeRetries++ - logger.error(withLogContext('Retrying async resume after claim contention'), { + reqLogger.info('Retrying async resume after claim contention', { checkpointId: continuation.checkpointId, runId: continuation.runId, workerId: resumeWorkerId, @@ -453,14 +421,11 @@ export async function orchestrateCopilotStream( await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries)) continue } - logger.error( - withLogContext('Async continuation failed because tool claims could not be acquired'), - { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - claimFailures, - } - ) + reqLogger.error('Async continuation failed because tool claims could not be acquired', { + checkpointId: continuation.checkpointId, + runId: continuation.runId, + claimFailures, + }) throw new Error( `Failed to resume async tool continuation: unable to claim tool calls (${claimFailures.join(', ')})` ) @@ -474,7 +439,7 @@ export async function orchestrateCopilotStream( ] claimedByWorkerId = claimedToolCallIds.length > 0 ? resumeWorkerId : null - logger.error(withLogContext('Resuming async tool continuation'), { + reqLogger.info('Resuming async tool continuation', { checkpointId: continuation.checkpointId, runId: continuation.runId, workerId: resumeWorkerId, @@ -514,10 +479,8 @@ export async function orchestrateCopilotStream( !isTerminalAsyncStatus(durableStatus) && !isDeliveredAsyncStatus(durableStatus) ) { - logger.warn( - withLogContext( - 'Async tool row was claimed for resume without terminal durable state' - ), + reqLogger.warn( + 'Async tool row was claimed for resume without terminal durable state', { toolCallId: tool.toolCallId, status: durableStatus, @@ -540,7 +503,7 @@ export async function orchestrateCopilotStream( checkpointId: continuation.checkpointId, results, } - logger.error(withLogContext('Prepared async continuation payload for resume endpoint'), { + reqLogger.info('Prepared async continuation payload for resume endpoint', { route, checkpointId: continuation.checkpointId, resultCount: results.length, @@ -550,7 +513,7 @@ export async function orchestrateCopilotStream( } if (!resumeReady) { - logger.warn(withLogContext('Async continuation loop exited without resume payload'), { + reqLogger.warn('Async continuation loop exited without resume payload', { checkpointId: continuation.checkpointId, runId: continuation.runId, }) @@ -569,7 +532,7 @@ export async function orchestrateCopilotStream( usage: context.usage, cost: context.cost, } - logger.error(withLogContext('Completing copilot orchestration'), { + reqLogger.info('Completing copilot orchestration', { success: result.success, chatId: result.chatId, hasRequestId: Boolean(result.requestId), @@ -581,7 +544,7 @@ export async function orchestrateCopilotStream( } catch (error) { const err = error instanceof Error ? error : new Error('Copilot orchestration failed') if (claimedToolCallIds.length > 0 && claimedByWorkerId) { - logger.warn(withLogContext('Releasing async tool claims after delivery failure'), { + reqLogger.warn('Releasing async tool claims after delivery failure', { toolCallIds: claimedToolCallIds, workerId: claimedByWorkerId, }) @@ -591,7 +554,7 @@ export async function orchestrateCopilotStream( ) ) } - logger.error(withLogContext('Copilot orchestration failed'), { + reqLogger.error('Copilot orchestration failed', { error: err.message, }) await options.onError?.(err) diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts index 3732fed983e..e3f1cd829df 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts @@ -1,7 +1,6 @@ import { createLogger } from '@sim/logger' import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { asRecord, getEventData, @@ -83,15 +82,12 @@ function abortPendingToolIfStreamDead( }, context.messageId ).catch((err) => { - logger.error( - appendCopilotLogContext('markToolComplete fire-and-forget failed (stream aborted)', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('markToolComplete fire-and-forget failed (stream aborted)', { toolCallId: toolCall.id, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) return true } @@ -136,15 +132,12 @@ function handleClientCompletion( { background: true }, context.messageId ).catch((err) => { - logger.error( - appendCopilotLogContext('markToolComplete fire-and-forget failed (client background)', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('markToolComplete fire-and-forget failed (client background)', { toolCallId: toolCall.id, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) markToolResultSeen(toolCallId) return @@ -160,15 +153,12 @@ function handleClientCompletion( undefined, context.messageId ).catch((err) => { - logger.error( - appendCopilotLogContext('markToolComplete fire-and-forget failed (client rejected)', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('markToolComplete fire-and-forget failed (client rejected)', { toolCallId: toolCall.id, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) markToolResultSeen(toolCallId) return @@ -184,15 +174,12 @@ function handleClientCompletion( completion.data, context.messageId ).catch((err) => { - logger.error( - appendCopilotLogContext('markToolComplete fire-and-forget failed (client cancelled)', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('markToolComplete fire-and-forget failed (client cancelled)', { toolCallId: toolCall.id, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) markToolResultSeen(toolCallId) return @@ -209,16 +196,13 @@ function handleClientCompletion( completion?.data, context.messageId ).catch((err) => { - logger.error( - appendCopilotLogContext('markToolComplete fire-and-forget failed (client completion)', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('markToolComplete fire-and-forget failed (client completion)', { toolCallId: toolCall.id, toolName: toolCall.name, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) markToolResultSeen(toolCallId) } @@ -252,16 +236,13 @@ async function emitSyntheticToolResult( error: !success ? completion?.message : undefined, } as SSEEvent) } catch (error) { - logger.warn( - appendCopilotLogContext('Failed to emit synthetic tool_result', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to emit synthetic tool_result', { toolCallId, toolName, error: error instanceof Error ? error.message : String(error), - } - ) + }) } } @@ -328,17 +309,14 @@ export const sseHandlers: Record = { const rid = typeof event.data === 'string' ? event.data : undefined if (rid) { context.requestId = rid - logger.error( - appendCopilotLogContext('Mapped copilot message to Go trace ID', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .info('Mapped copilot message to Go trace ID', { goTraceId: rid, chatId: context.chatId, executionId: context.executionId, runId: context.runId, - } - ) + }) } }, title_updated: () => {}, @@ -485,29 +463,23 @@ export const sseHandlers: Record = { args, }) } catch (err) { - logger.warn( - appendCopilotLogContext('Failed to persist async tool row before execution', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to persist async tool row before execution', { toolCallId, toolName, error: err instanceof Error ? err.message : String(err), - } - ) + }) } return executeToolAndReport(toolCallId, context, execContext, options) })().catch((err) => { - logger.error( - appendCopilotLogContext('Parallel tool execution failed', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('Parallel tool execution failed', { toolCallId, toolName, error: err instanceof Error ? err.message : String(err), - } - ) + }) return { status: 'error', message: err instanceof Error ? err.message : String(err), @@ -546,16 +518,13 @@ export const sseHandlers: Record = { args, status: 'running', }).catch((err) => { - logger.warn( - appendCopilotLogContext('Failed to persist async tool row for client-executable tool', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to persist async tool row for client-executable tool', { toolCallId, toolName, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) const clientWaitSignal = buildClientToolAbortSignal(options) const completion = await waitForToolCompletion( @@ -746,29 +715,23 @@ export const subAgentHandlers: Record = { args, }) } catch (err) { - logger.warn( - appendCopilotLogContext('Failed to persist async subagent tool row before execution', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to persist async subagent tool row before execution', { toolCallId, toolName, error: err instanceof Error ? err.message : String(err), - } - ) + }) } return executeToolAndReport(toolCallId, context, execContext, options) })().catch((err) => { - logger.error( - appendCopilotLogContext('Parallel subagent tool execution failed', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('Parallel subagent tool execution failed', { toolCallId, toolName, error: err instanceof Error ? err.message : String(err), - } - ) + }) return { status: 'error', message: err instanceof Error ? err.message : String(err), @@ -802,17 +765,13 @@ export const subAgentHandlers: Record = { args, status: 'running', }).catch((err) => { - logger.warn( - appendCopilotLogContext( - 'Failed to persist async tool row for client-executable subagent tool', - { messageId: context.messageId } - ), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to persist async tool row for client-executable subagent tool', { toolCallId, toolName, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) const clientWaitSignal = buildClientToolAbortSignal(options) const completion = await waitForToolCompletion( @@ -881,15 +840,12 @@ export const subAgentHandlers: Record = { export function handleSubagentRouting(event: SSEEvent, context: StreamingContext): boolean { if (!event.subagent) return false if (!context.subAgentParentToolCallId) { - logger.warn( - appendCopilotLogContext('Subagent event missing parent tool call', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Subagent event missing parent tool call', { type: event.type, subagent: event.subagent, - } - ) + }) return false } return true diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts index 445075ef992..e0296f8b525 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts @@ -3,7 +3,6 @@ import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { completeAsyncToolCall, markAsyncToolRunning } from '@/lib/copilot/async-runs/repository' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { waitForToolConfirmation } from '@/lib/copilot/orchestrator/persistence' import { asRecord, markToolResultSeen } from '@/lib/copilot/orchestrator/sse/utils' import { executeToolServerSide, markToolComplete } from '@/lib/copilot/orchestrator/tool-executor' @@ -187,15 +186,12 @@ async function maybeWriteOutputToFile( contentType ) - logger.error( - appendCopilotLogContext('Tool output written to file', { messageId: context.messageId }), - { - toolName, - fileName, - size: buffer.length, - fileId: uploaded.id, - } - ) + logger.withMetadata({ messageId: context.messageId }).info('Tool output written to file', { + toolName, + fileName, + size: buffer.length, + fileId: uploaded.id, + }) return { success: true, @@ -209,16 +205,13 @@ async function maybeWriteOutputToFile( } } catch (err) { const message = err instanceof Error ? err.message : String(err) - logger.warn( - appendCopilotLogContext('Failed to write tool output to file', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to write tool output to file', { toolName, outputPath, error: message, - } - ) + }) return { success: false, error: `Failed to write output file: ${message}`, @@ -306,7 +299,7 @@ function reportCancelledTool( data: Record = { cancelled: true } ): void { markToolComplete(toolCall.id, toolCall.name, 499, message, data, messageId).catch((err) => { - logger.error(appendCopilotLogContext('markToolComplete failed (cancelled)', { messageId }), { + logger.withMetadata({ messageId }).error('markToolComplete failed (cancelled)', { toolCallId: toolCall.id, toolName: toolCall.name, error: err instanceof Error ? err.message : String(err), @@ -401,14 +394,11 @@ async function maybeWriteOutputToTable( } }) - logger.error( - appendCopilotLogContext('Tool output written to table', { messageId: context.messageId }), - { - toolName, - tableId: outputTable, - rowCount: rows.length, - } - ) + logger.withMetadata({ messageId: context.messageId }).info('Tool output written to table', { + toolName, + tableId: outputTable, + rowCount: rows.length, + }) return { success: true, @@ -419,16 +409,13 @@ async function maybeWriteOutputToTable( }, } } catch (err) { - logger.warn( - appendCopilotLogContext('Failed to write tool output to table', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to write tool output to table', { toolName, outputTable, error: err instanceof Error ? err.message : String(err), - } - ) + }) return { success: false, error: `Failed to write to table: ${err instanceof Error ? err.message : String(err)}`, @@ -528,16 +515,13 @@ async function maybeWriteReadCsvToTable( } }) - logger.error( - appendCopilotLogContext('Read output written to table', { messageId: context.messageId }), - { - toolName, - tableId: outputTable, - tableName: table.name, - rowCount: rows.length, - filePath, - } - ) + logger.withMetadata({ messageId: context.messageId }).info('Read output written to table', { + toolName, + tableId: outputTable, + tableName: table.name, + rowCount: rows.length, + filePath, + }) return { success: true, @@ -549,16 +533,13 @@ async function maybeWriteReadCsvToTable( }, } } catch (err) { - logger.warn( - appendCopilotLogContext('Failed to write read output to table', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to write read output to table', { toolName, outputTable, error: err instanceof Error ? err.message : String(err), - } - ) + }) return { success: false, error: `Failed to import into table: ${err instanceof Error ? err.message : String(err)}`, @@ -599,14 +580,11 @@ export async function executeToolAndReport( toolCall.status = 'executing' await markAsyncToolRunning(toolCall.id, 'sim-stream').catch(() => {}) - logger.error( - appendCopilotLogContext('Tool execution started', { messageId: context.messageId }), - { - toolCallId: toolCall.id, - toolName: toolCall.name, - params: toolCall.params, - } - ) + logger.withMetadata({ messageId: context.messageId }).info('Tool execution started', { + toolCallId: toolCall.id, + toolName: toolCall.name, + params: toolCall.params, + }) try { let result = await executeToolServerSide(toolCall, execContext) @@ -693,24 +671,18 @@ export async function executeToolAndReport( : raw && typeof raw === 'object' ? JSON.stringify(raw).slice(0, 200) : undefined - logger.error( - appendCopilotLogContext('Tool execution succeeded', { messageId: context.messageId }), - { - toolCallId: toolCall.id, - toolName: toolCall.name, - outputPreview: preview, - } - ) + logger.withMetadata({ messageId: context.messageId }).info('Tool execution succeeded', { + toolCallId: toolCall.id, + toolName: toolCall.name, + outputPreview: preview, + }) } else { - logger.warn( - appendCopilotLogContext('Tool execution failed', { messageId: context.messageId }), - { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: result.error, - params: toolCall.params, - } - ) + logger.withMetadata({ messageId: context.messageId }).warn('Tool execution failed', { + toolCallId: toolCall.id, + toolName: toolCall.name, + error: result.error, + params: toolCall.params, + }) } // If create_workflow was successful, update the execution context with the new workflowId. @@ -760,16 +732,13 @@ export async function executeToolAndReport( result.output, context.messageId ).catch((err) => { - logger.error( - appendCopilotLogContext('markToolComplete fire-and-forget failed', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('markToolComplete fire-and-forget failed', { toolCallId: toolCall.id, toolName: toolCall.name, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) const resultEvent: SSEEvent = { @@ -804,15 +773,12 @@ export async function executeToolAndReport( if (deleted.length > 0) { isDeleteOp = true removeChatResources(execContext.chatId, deleted).catch((err) => { - logger.warn( - appendCopilotLogContext('Failed to remove chat resources after deletion', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to remove chat resources after deletion', { chatId: execContext.chatId, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) for (const resource of deleted) { @@ -835,15 +801,12 @@ export async function executeToolAndReport( if (resources.length > 0) { persistChatResources(execContext.chatId, resources).catch((err) => { - logger.warn( - appendCopilotLogContext('Failed to persist chat resources', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .warn('Failed to persist chat resources', { chatId: execContext.chatId, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) for (const resource of resources) { @@ -879,15 +842,12 @@ export async function executeToolAndReport( toolCall.error = error instanceof Error ? error.message : String(error) toolCall.endTime = Date.now() - logger.error( - appendCopilotLogContext('Tool execution threw', { messageId: context.messageId }), - { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: toolCall.error, - params: toolCall.params, - } - ) + logger.withMetadata({ messageId: context.messageId }).error('Tool execution threw', { + toolCallId: toolCall.id, + toolName: toolCall.name, + error: toolCall.error, + params: toolCall.params, + }) markToolResultSeen(toolCall.id) await completeAsyncToolCall({ @@ -909,16 +869,13 @@ export async function executeToolAndReport( }, context.messageId ).catch((err) => { - logger.error( - appendCopilotLogContext('markToolComplete fire-and-forget failed', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('markToolComplete fire-and-forget failed', { toolCallId: toolCall.id, toolName: toolCall.name, error: err instanceof Error ? err.message : String(err), - } - ) + }) }) const errorEvent: SSEEvent = { diff --git a/apps/sim/lib/copilot/orchestrator/stream/core.ts b/apps/sim/lib/copilot/orchestrator/stream/core.ts index 79f5facb47d..1dccfa2700c 100644 --- a/apps/sim/lib/copilot/orchestrator/stream/core.ts +++ b/apps/sim/lib/copilot/orchestrator/stream/core.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isPaid } from '@/lib/billing/plan-helpers' import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { handleSubagentRouting, sseHandlers, @@ -165,13 +164,10 @@ export async function runStreamLoop( try { await options.onEvent?.(normalizedEvent) } catch (error) { - logger.warn( - appendCopilotLogContext('Failed to forward SSE event', { messageId: context.messageId }), - { - type: normalizedEvent.type, - error: error instanceof Error ? error.message : String(error), - } - ) + logger.withMetadata({ messageId: context.messageId }).warn('Failed to forward SSE event', { + type: normalizedEvent.type, + error: error instanceof Error ? error.message : String(error), + }) } // Let the caller intercept before standard dispatch. @@ -205,11 +201,9 @@ export async function runStreamLoop( if (context.subAgentParentStack.length > 0) { context.subAgentParentStack.pop() } else { - logger.warn( - appendCopilotLogContext('subagent_end without matching subagent_start', { - messageId: context.messageId, - }) - ) + logger + .withMetadata({ messageId: context.messageId }) + .warn('subagent_end without matching subagent_start') } context.subAgentParentToolCallId = context.subAgentParentStack.length > 0 diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index c4fb11fac3e..e824f25a064 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -3,7 +3,6 @@ import { credential, mcpServers, pendingCredentialDraft, user } from '@sim/db/sc import { createLogger } from '@sim/logger' import { and, eq, isNull, lt } from 'drizzle-orm' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import type { ExecutionContext, ToolCallResult, @@ -322,17 +321,14 @@ async function executeManageCustomTool( error: `Unsupported operation for manage_custom_tool: ${operation}`, } } catch (error) { - logger.error( - appendCopilotLogContext('manage_custom_tool execution failed', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('manage_custom_tool execution failed', { operation, workspaceId, userId: context.userId, error: error instanceof Error ? error.message : String(error), - } - ) + }) return { success: false, error: error instanceof Error ? error.message : 'Failed to manage custom tool', @@ -559,16 +555,13 @@ async function executeManageMcpTool( return { success: false, error: `Unsupported operation for manage_mcp_tool: ${operation}` } } catch (error) { - logger.error( - appendCopilotLogContext('manage_mcp_tool execution failed', { - messageId: context.messageId, - }), - { + logger + .withMetadata({ messageId: context.messageId }) + .error('manage_mcp_tool execution failed', { operation, workspaceId, error: error instanceof Error ? error.message : String(error), - } - ) + }) return { success: false, error: error instanceof Error ? error.message : 'Failed to manage MCP server', @@ -727,16 +720,11 @@ async function executeManageSkill( return { success: false, error: `Unsupported operation for manage_skill: ${operation}` } } catch (error) { - logger.error( - appendCopilotLogContext('manage_skill execution failed', { - messageId: context.messageId, - }), - { - operation, - workspaceId, - error: error instanceof Error ? error.message : String(error), - } - ) + logger.withMetadata({ messageId: context.messageId }).error('manage_skill execution failed', { + operation, + workspaceId, + error: error instanceof Error ? error.message : String(error), + }) return { success: false, error: error instanceof Error ? error.message : 'Failed to manage skill', @@ -1007,15 +995,12 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record< }, } } catch (err) { - logger.warn( - appendCopilotLogContext('Failed to generate OAuth link, falling back to generic URL', { - messageId: c.messageId, - }), - { + logger + .withMetadata({ messageId: c.messageId }) + .warn('Failed to generate OAuth link, falling back to generic URL', { providerName, error: err instanceof Error ? err.message : String(err), - } - ) + }) const workspaceUrl = c.workspaceId ? `${baseUrl}/workspace/${c.workspaceId}` : `${baseUrl}/workspace` @@ -1199,12 +1184,9 @@ export async function executeToolServerSide( const toolConfig = getTool(resolvedToolName) if (!toolConfig) { - logger.warn( - appendCopilotLogContext('Tool not found in registry', { - messageId: context.messageId, - }), - { toolName, resolvedToolName } - ) + logger + .withMetadata({ messageId: context.messageId }) + .warn('Tool not found in registry', { toolName, resolvedToolName }) return { success: false, error: `Tool not found: ${toolName}`, @@ -1293,15 +1275,10 @@ async function executeServerToolDirect( return { success: true, output: result } } catch (error) { - logger.error( - appendCopilotLogContext('Server tool execution failed', { - messageId: context.messageId, - }), - { - toolName, - error: error instanceof Error ? error.message : String(error), - } - ) + logger.withMetadata({ messageId: context.messageId }).error('Server tool execution failed', { + toolName, + error: error instanceof Error ? error.message : String(error), + }) return { success: false, error: error instanceof Error ? error.message : 'Server tool execution failed', @@ -1377,7 +1354,7 @@ export async function markToolComplete( }) if (!response.ok) { - logger.warn(appendCopilotLogContext('Mark-complete call failed', { messageId }), { + logger.withMetadata({ messageId }).warn('Mark-complete call failed', { toolCallId, toolName, status: response.status, @@ -1391,7 +1368,7 @@ export async function markToolComplete( } } catch (error) { const isTimeout = error instanceof DOMException && error.name === 'AbortError' - logger.error(appendCopilotLogContext('Mark-complete call failed', { messageId }), { + logger.withMetadata({ messageId }).error('Mark-complete call failed', { toolCallId, toolName, timedOut: isTimeout, diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index fbae8b4dcc0..845aac805b6 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { assertServerToolNotAborted, type BaseServerTool, @@ -125,8 +124,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< params: DownloadToWorkspaceFileArgs, context?: ServerToolContext ): Promise { - const withMessageId = (message: string) => - appendCopilotLogContext(message, { messageId: context?.messageId }) + const reqLogger = logger.withMetadata({ messageId: context?.messageId }) if (!context?.userId) { throw new Error('Authentication required') @@ -178,7 +176,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< mimeType ) - logger.info(withMessageId('Downloaded remote file to workspace'), { + reqLogger.info('Downloaded remote file to workspace', { sourceUrl: params.url, fileId: uploaded.id, fileName: uploaded.name, @@ -195,7 +193,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error' - logger.error(withMessageId('Failed to download file to workspace'), { + reqLogger.error('Failed to download file to workspace', { url: params.url, error: msg, }) diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 538ea4f52fa..76a0d1fdafe 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { assertServerToolNotAborted, type BaseServerTool, @@ -51,11 +50,10 @@ export const workspaceFileServerTool: BaseServerTool { - const withMessageId = (message: string) => - appendCopilotLogContext(message, { messageId: context?.messageId }) + const reqLogger = logger.withMetadata({ messageId: context?.messageId }) if (!context?.userId) { - logger.error(withMessageId('Unauthorized attempt to access workspace files')) + reqLogger.error('Unauthorized attempt to access workspace files') throw new Error('Authentication required') } @@ -94,7 +92,7 @@ export const workspaceFileServerTool: BaseServerTool { - const withMessageId = (message: string) => - appendCopilotLogContext(message, { messageId: context?.messageId }) + const reqLogger = logger.withMetadata({ messageId: context?.messageId }) if (!context?.userId) { throw new Error('Authentication required') @@ -97,17 +95,17 @@ export const generateImageServerTool: BaseServerTool = { name: 'get_job_logs', async execute(rawArgs: GetJobLogsArgs, context?: ServerToolContext): Promise { - const withMessageId = (message: string) => - appendCopilotLogContext(message, { messageId: context?.messageId }) + const reqLogger = logger.withMetadata({ messageId: context?.messageId }) const { jobId, @@ -114,7 +112,7 @@ export const getJobLogsServerTool: BaseServerTool const clampedLimit = Math.min(Math.max(1, limit), 5) - logger.info(withMessageId('Fetching job logs'), { + reqLogger.info('Fetching job logs', { jobId, executionId, limit: clampedLimit, @@ -173,7 +171,7 @@ export const getJobLogsServerTool: BaseServerTool return entry }) - logger.info(withMessageId('Job logs prepared'), { + reqLogger.info('Job logs prepared', { jobId, count: entries.length, resultSizeKB: Math.round(JSON.stringify(entries).length / 1024), diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 2eae12f37ef..d0115015699 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -3,7 +3,6 @@ import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { generateInternalToken } from '@/lib/auth/internal' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { assertServerToolNotAborted, type BaseServerTool, @@ -48,14 +47,11 @@ export const knowledgeBaseServerTool: BaseServerTool { - const withMessageId = (message: string) => - appendCopilotLogContext(message, { messageId: context?.messageId }) + const reqLogger = logger.withMetadata({ messageId: context?.messageId }) if (!context?.userId) { - logger.error( - withMessageId( - 'Unauthorized attempt to access knowledge base - no authenticated user context' - ) + reqLogger.error( + 'Unauthorized attempt to access knowledge base - no authenticated user context' ) throw new Error('Authentication required') } @@ -105,7 +101,7 @@ export const knowledgeBaseServerTool: BaseServerTool { - logger.error(withMessageId('Background document processing failed'), { + reqLogger.error('Background document processing failed', { documentId: doc.id, error: err instanceof Error ? err.message : String(err), }) }) - logger.info(withMessageId('Workspace file added to knowledge base via copilot'), { + reqLogger.info('Workspace file added to knowledge base via copilot', { knowledgeBaseId: args.knowledgeBaseId, documentId: doc.id, fileName: fileRecord.name, @@ -352,7 +348,7 @@ export const knowledgeBaseServerTool: BaseServerTool = { name: 'user_table', async execute(params: UserTableArgs, context?: ServerToolContext): Promise { - const withMessageId = (message: string) => - appendCopilotLogContext(message, { messageId: context?.messageId }) + const reqLogger = logger.withMetadata({ messageId: context?.messageId }) if (!context?.userId) { - logger.error( - withMessageId('Unauthorized attempt to access user table - no authenticated user context') - ) + logger.error('Unauthorized attempt to access user table - no authenticated user context') throw new Error('Authentication required') } @@ -729,7 +725,7 @@ export const userTableServerTool: BaseServerTool const coerced = coerceRows(rows, columns, columnMap) const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - logger.info(withMessageId('Table created from file'), { + reqLogger.info('Table created from file', { tableId: table.id, fileName: file.name, columns: columns.length, @@ -805,7 +801,7 @@ export const userTableServerTool: BaseServerTool const coerced = coerceRows(rows, matchedColumns, columnMap) const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - logger.info(withMessageId('Rows imported from file'), { + reqLogger.info('Rows imported from file', { tableId: table.id, fileName: file.name, matchedColumns: mappedHeaders.length, @@ -1003,7 +999,7 @@ export const userTableServerTool: BaseServerTool ? error.cause.message : String(error.cause) : undefined - logger.error(withMessageId('Table operation failed'), { + reqLogger.error('Table operation failed', { operation, error: errorMessage, cause, diff --git a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts index abceb9d9d50..f58c40c4a9f 100644 --- a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts +++ b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { appendCopilotLogContext } from '@/lib/copilot/logging' import { assertServerToolNotAborted, type BaseServerTool, @@ -66,7 +65,7 @@ async function collectSandboxFiles( inputTables?: string[], messageId?: string ): Promise { - const withMessageId = (message: string) => appendCopilotLogContext(message, { messageId }) + const reqLogger = logger.withMetadata({ messageId }) const sandboxFiles: SandboxFile[] = [] let totalSize = 0 @@ -75,12 +74,12 @@ async function collectSandboxFiles( for (const fileRef of inputFiles) { const record = findWorkspaceFileRecord(allFiles, fileRef) if (!record) { - logger.warn(withMessageId('Sandbox input file not found'), { fileRef }) + reqLogger.warn('Sandbox input file not found', { fileRef }) continue } const ext = record.name.split('.').pop()?.toLowerCase() ?? '' if (!TEXT_EXTENSIONS.has(ext)) { - logger.warn(withMessageId('Skipping non-text sandbox input file'), { + reqLogger.warn('Skipping non-text sandbox input file', { fileId: record.id, fileName: record.name, ext, @@ -88,7 +87,7 @@ async function collectSandboxFiles( continue } if (record.size > MAX_FILE_SIZE) { - logger.warn(withMessageId('Sandbox input file exceeds size limit'), { + reqLogger.warn('Sandbox input file exceeds size limit', { fileId: record.id, fileName: record.name, size: record.size, @@ -96,9 +95,7 @@ async function collectSandboxFiles( continue } if (totalSize + record.size > MAX_TOTAL_SIZE) { - logger.warn( - withMessageId('Sandbox input total size limit reached, skipping remaining files') - ) + logger.warn('Sandbox input total size limit reached, skipping remaining files') break } const buffer = await downloadWorkspaceFile(record) @@ -119,7 +116,7 @@ async function collectSandboxFiles( for (const tableId of inputTables) { const table = await getTableById(tableId) if (!table) { - logger.warn(withMessageId('Sandbox input table not found'), { tableId }) + reqLogger.warn('Sandbox input table not found', { tableId }) continue } const { rows } = await queryRows(tableId, workspaceId, { limit: 10000 }, 'sandbox-input') @@ -134,9 +131,7 @@ async function collectSandboxFiles( } const csvContent = csvLines.join('\n') if (totalSize + csvContent.length > MAX_TOTAL_SIZE) { - logger.warn( - withMessageId('Sandbox input total size limit reached, skipping remaining tables') - ) + logger.warn('Sandbox input total size limit reached, skipping remaining tables') break } totalSize += csvContent.length @@ -157,8 +152,7 @@ export const generateVisualizationServerTool: BaseServerTool< params: VisualizationArgs, context?: ServerToolContext ): Promise { - const withMessageId = (message: string) => - appendCopilotLogContext(message, { messageId: context?.messageId }) + const reqLogger = logger.withMetadata({ messageId: context?.messageId }) if (!context?.userId) { throw new Error('Authentication required') @@ -243,7 +237,7 @@ export const generateVisualizationServerTool: BaseServerTool< imageBuffer, 'image/png' ) - logger.info(withMessageId('Chart image overwritten'), { + reqLogger.info('Chart image overwritten', { fileId: updated.id, fileName: updated.name, size: imageBuffer.length, @@ -267,7 +261,7 @@ export const generateVisualizationServerTool: BaseServerTool< 'image/png' ) - logger.info(withMessageId('Chart image saved'), { + reqLogger.info('Chart image saved', { fileId: uploaded.id, fileName: uploaded.name, size: imageBuffer.length, @@ -282,7 +276,7 @@ export const generateVisualizationServerTool: BaseServerTool< } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error' - logger.error(withMessageId('Visualization generation failed'), { error: msg }) + reqLogger.error('Visualization generation failed', { error: msg }) return { success: false, message: `Failed to generate visualization: ${msg}` } } }, diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index b21a8c9eaad..83f97e6cd58 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -153,8 +153,9 @@ export class ExecutionLogger implements IExecutionLoggerService { workflowState, deploymentVersionId, } = params + const execLog = logger.withMetadata({ workflowId, workspaceId, executionId }) - logger.debug(`Starting workflow execution ${executionId} for workflow ${workflowId}`) + execLog.debug('Starting workflow execution') // Check if execution log already exists (idempotency check) const existingLog = await db @@ -164,9 +165,7 @@ export class ExecutionLogger implements IExecutionLoggerService { .limit(1) if (existingLog.length > 0) { - logger.debug( - `Execution log already exists for ${executionId}, skipping duplicate INSERT (idempotent)` - ) + execLog.debug('Execution log already exists, skipping duplicate INSERT (idempotent)') const snapshot = await snapshotService.getSnapshot(existingLog[0].stateSnapshotId) if (!snapshot) { throw new Error(`Snapshot ${existingLog[0].stateSnapshotId} not found for existing log`) @@ -228,7 +227,7 @@ export class ExecutionLogger implements IExecutionLoggerService { }) .returning() - logger.debug(`Created workflow log ${workflowLog.id} for execution ${executionId}`) + execLog.debug('Created workflow log', { logId: workflowLog.id }) return { workflowLog: { @@ -298,13 +297,20 @@ export class ExecutionLogger implements IExecutionLoggerService { status: statusOverride, } = params - logger.debug(`Completing workflow execution ${executionId}`, { isResume }) + let execLog = logger.withMetadata({ executionId }) + execLog.debug('Completing workflow execution', { isResume }) const [existingLog] = await db .select() .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) + if (existingLog) { + execLog = execLog.withMetadata({ + workflowId: existingLog.workflowId ?? undefined, + workspaceId: existingLog.workspaceId ?? undefined, + }) + } const billingUserId = this.extractBillingUserId(existingLog?.executionData) const existingExecutionData = existingLog?.executionData as | WorkflowExecutionLog['executionData'] @@ -507,10 +513,10 @@ export class ExecutionLogger implements IExecutionLoggerService { billingUserId ) } catch {} - logger.warn('Usage threshold notification check failed (non-fatal)', { error: e }) + execLog.warn('Usage threshold notification check failed (non-fatal)', { error: e }) } - logger.debug(`Completed workflow execution ${executionId}`) + execLog.debug('Completed workflow execution') const completedLog: WorkflowExecutionLog = { id: updatedLog.id, @@ -528,10 +534,7 @@ export class ExecutionLogger implements IExecutionLoggerService { } emitWorkflowExecutionCompleted(completedLog).catch((error) => { - logger.error('Failed to emit workflow execution completed event', { - error, - executionId, - }) + execLog.error('Failed to emit workflow execution completed event', { error }) }) return completedLog @@ -608,18 +611,20 @@ export class ExecutionLogger implements IExecutionLoggerService { executionId?: string, billingUserId?: string | null ): Promise { + const statsLog = logger.withMetadata({ workflowId: workflowId ?? undefined, executionId }) + if (!isBillingEnabled) { - logger.debug('Billing is disabled, skipping user stats cost update') + statsLog.debug('Billing is disabled, skipping user stats cost update') return } if (costSummary.totalCost <= 0) { - logger.debug('No cost to update in user stats') + statsLog.debug('No cost to update in user stats') return } if (!workflowId) { - logger.debug('Workflow was deleted, skipping user stats update') + statsLog.debug('Workflow was deleted, skipping user stats update') return } @@ -631,16 +636,14 @@ export class ExecutionLogger implements IExecutionLoggerService { .limit(1) if (!workflowRecord) { - logger.error(`Workflow ${workflowId} not found for user stats update`) + statsLog.error('Workflow not found for user stats update') return } const userId = billingUserId?.trim() || null if (!userId) { - logger.error('Missing billing actor in execution context; skipping stats update', { - workflowId, + statsLog.error('Missing billing actor in execution context; skipping stats update', { trigger, - executionId, }) return } @@ -702,8 +705,7 @@ export class ExecutionLogger implements IExecutionLoggerService { // Check if user has hit overage threshold and bill incrementally await checkAndBillOverageThreshold(userId) } catch (error) { - logger.error('Error updating user stats with cost information', { - workflowId, + statsLog.error('Error updating user stats with cost information', { error, costSummary, }) diff --git a/packages/logger/src/index.test.ts b/packages/logger/src/index.test.ts index 48652a34e95..db14f191603 100644 --- a/packages/logger/src/index.test.ts +++ b/packages/logger/src/index.test.ts @@ -154,4 +154,63 @@ describe('Logger', () => { }).not.toThrow() }) }) + + describe('withMetadata', () => { + const createEnabledLogger = () => + new Logger('Test', { enabled: true, colorize: false, logLevel: LogLevel.DEBUG }) + + test('should return a new Logger instance', () => { + const logger = createEnabledLogger() + const child = logger.withMetadata({ workflowId: 'wf_1' }) + expect(child).toBeInstanceOf(Logger) + expect(child).not.toBe(logger) + }) + + test('should include metadata in log output', () => { + const child = createEnabledLogger().withMetadata({ workflowId: 'wf_1' }) + child.info('hello') + expect(consoleLogSpy).toHaveBeenCalledTimes(1) + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{workflowId=wf_1}') + }) + + test('should not affect original logger output', () => { + const logger = createEnabledLogger() + logger.withMetadata({ workflowId: 'wf_1' }) + logger.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).not.toContain('workflowId') + }) + + test('should merge metadata across chained calls', () => { + const child = createEnabledLogger().withMetadata({ a: '1' }).withMetadata({ b: '2' }) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{a=1 b=2}') + }) + + test('should override parent metadata for same key', () => { + const child = createEnabledLogger().withMetadata({ a: '1' }).withMetadata({ a: '2' }) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{a=2}') + expect(prefix).not.toContain('a=1') + }) + + test('should exclude undefined values from output', () => { + const child = createEnabledLogger().withMetadata({ a: '1', b: undefined }) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).toContain('{a=1}') + expect(prefix).not.toContain('b=') + }) + + test('should produce no metadata segment when metadata is empty', () => { + const child = createEnabledLogger().withMetadata({}) + child.info('hello') + const prefix = consoleLogSpy.mock.calls[0][0] as string + expect(prefix).not.toContain('{') + expect(prefix).not.toContain('}') + }) + }) }) diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index ab848051222..eb28fec3fcf 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -33,6 +33,12 @@ export interface LoggerConfig { enabled?: boolean } +/** + * Metadata key-value pairs attached to a logger instance. + * Included automatically in every log line produced by that logger. + */ +export type LoggerMetadata = Record + const getNodeEnv = (): string => { if (typeof process !== 'undefined' && process.env) { return process.env.NODE_ENV || 'development' @@ -141,6 +147,7 @@ export class Logger { private module: string private config: ReturnType private isDev: boolean + private metadata: LoggerMetadata = {} /** * Create a new logger for a specific module @@ -172,6 +179,20 @@ export class Logger { } } + /** + * Creates a child logger with additional metadata merged in. + * The child inherits this logger's module name, config, and existing metadata. + * New metadata keys override existing ones with the same name. + */ + withMetadata(metadata: LoggerMetadata): Logger { + const child = Object.create(Logger.prototype) as Logger + child.module = this.module + child.config = this.config + child.isDev = this.isDev + child.metadata = { ...this.metadata, ...metadata } + return child + } + /** * Determines if a log at the given level should be displayed */ @@ -209,6 +230,12 @@ export class Logger { const timestamp = new Date().toISOString() const formattedArgs = this.formatArgs(args) + const metadataEntries = Object.entries(this.metadata).filter(([_, v]) => v !== undefined) + const metadataStr = + metadataEntries.length > 0 + ? ` {${metadataEntries.map(([k, v]) => `${k}=${v}`).join(' ')}}` + : '' + if (this.config.colorize) { let levelColor: (text: string) => string const moduleColor = chalk.cyan @@ -229,7 +256,8 @@ export class Logger { break } - const coloredPrefix = `${timestampColor(`[${timestamp}]`)} ${levelColor(`[${level}]`)} ${moduleColor(`[${this.module}]`)}` + const coloredMeta = metadataStr ? ` ${chalk.magenta(metadataStr.trim())}` : '' + const coloredPrefix = `${timestampColor(`[${timestamp}]`)} ${levelColor(`[${level}]`)} ${moduleColor(`[${this.module}]`)}${coloredMeta}` if (level === LogLevel.ERROR) { console.error(coloredPrefix, message, ...formattedArgs) @@ -237,7 +265,7 @@ export class Logger { console.log(coloredPrefix, message, ...formattedArgs) } } else { - const prefix = `[${timestamp}] [${level}] [${this.module}]` + const prefix = `[${timestamp}] [${level}] [${this.module}]${metadataStr}` if (level === LogLevel.ERROR) { console.error(prefix, message, ...formattedArgs) diff --git a/packages/testing/src/mocks/logger.mock.ts b/packages/testing/src/mocks/logger.mock.ts index 50c25122b3a..a71eedb1ecc 100644 --- a/packages/testing/src/mocks/logger.mock.ts +++ b/packages/testing/src/mocks/logger.mock.ts @@ -21,6 +21,7 @@ export function createMockLogger() { trace: vi.fn(), fatal: vi.fn(), child: vi.fn(() => createMockLogger()), + withMetadata: vi.fn(() => createMockLogger()), } } @@ -60,4 +61,5 @@ export function clearLoggerMocks(logger: ReturnType) { logger.debug.mockClear() logger.trace.mockClear() logger.fatal.mockClear() + logger.withMetadata.mockClear() } From 5c334874eba7b57a5958368235436a732c0ba085 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 16:11:04 -0700 Subject: [PATCH 05/15] fix(auth): use standard 'Unauthorized' error in hybrid auth responses (#3850) --- apps/sim/lib/auth/hybrid.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index 67fc19a36af..dd7c3913fad 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -154,7 +154,7 @@ export async function checkSessionOrInternalAuth( return { success: false, - error: 'Authentication required - provide session or internal JWT', + error: 'Unauthorized', } } catch (error) { logger.error('Error in session/internal authentication:', error) @@ -225,7 +225,7 @@ export async function checkHybridAuth( // No authentication found return { success: false, - error: 'Authentication required - provide session, API key, or internal JWT', + error: 'Unauthorized', } } catch (error) { logger.error('Error in hybrid authentication:', error) From 4ae5b1b6209b3f3b9a6cb6fc2c0df4156f6c22bb Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 16:20:30 -0700 Subject: [PATCH 06/15] improvement(workflow): use DOM hit-testing for edge drop-on-block detection (#3851) --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 57 +++++++------------ 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 5000e67a9dc..fd69e48bbc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -263,7 +263,7 @@ const WorkflowContent = React.memo( const params = useParams() const router = useRouter() const reactFlowInstance = useReactFlow() - const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance + const { screenToFlowPosition, getNodes, setNodes } = reactFlowInstance const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance, { embedded, }) @@ -2849,38 +2849,29 @@ const WorkflowContent = React.memo( ) /** - * Finds the best node at a given flow position for drop-on-block connection. - * Skips subflow containers as they have their own connection logic. + * Finds the node under the cursor using DOM hit-testing for pixel-perfect + * detection that matches exactly what the user sees on screen. + * Uses the same approach as ReactFlow's internal handle detection. */ - const findNodeAtPosition = useCallback( - (position: { x: number; y: number }) => { - const cursorRect = { - x: position.x - 1, - y: position.y - 1, - width: 2, - height: 2, - } + const findNodeAtScreenPosition = useCallback( + (clientX: number, clientY: number) => { + const elements = document.elementsFromPoint(clientX, clientY) + const nodes = getNodes() - const intersecting = getIntersectingNodes(cursorRect, true).filter( - (node) => node.type !== 'subflowNode' - ) + for (const el of elements) { + const nodeEl = el.closest('.react-flow__node') as HTMLElement | null + if (!nodeEl) continue - if (intersecting.length === 0) return undefined - if (intersecting.length === 1) return intersecting[0] + const nodeId = nodeEl.getAttribute('data-id') + if (!nodeId) continue - return intersecting.reduce((closest, node) => { - const getDistance = (n: Node) => { - const absPos = getNodeAbsolutePosition(n.id) - const dims = getBlockDimensions(n.id) - const centerX = absPos.x + dims.width / 2 - const centerY = absPos.y + dims.height / 2 - return Math.hypot(position.x - centerX, position.y - centerY) - } + const node = nodes.find((n) => n.id === nodeId) + if (node && node.type !== 'subflowNode') return node + } - return getDistance(node) < getDistance(closest) ? node : closest - }) + return undefined }, - [getIntersectingNodes, getNodeAbsolutePosition, getBlockDimensions] + [getNodes] ) /** @@ -3005,15 +2996,9 @@ const WorkflowContent = React.memo( return } - // Get cursor position in flow coordinates + // Find node under cursor using DOM hit-testing const clientPos = 'changedTouches' in event ? event.changedTouches[0] : event - const flowPosition = screenToFlowPosition({ - x: clientPos.clientX, - y: clientPos.clientY, - }) - - // Find node under cursor - const targetNode = findNodeAtPosition(flowPosition) + const targetNode = findNodeAtScreenPosition(clientPos.clientX, clientPos.clientY) // Create connection if valid target found (handle-to-body case) if (targetNode && targetNode.id !== source.nodeId) { @@ -3027,7 +3012,7 @@ const WorkflowContent = React.memo( connectionSourceRef.current = null }, - [screenToFlowPosition, findNodeAtPosition, onConnect] + [findNodeAtScreenPosition, onConnect] ) /** Handles node drag to detect container intersections and update highlighting. */ From e5aef6184a1617c9d8cd67ab32ab091b9cfb4a23 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 16:30:06 -0700 Subject: [PATCH 07/15] feat(profound): add Profound AI visibility and analytics integration (#3849) * feat(profound): add Profound AI visibility and analytics integration * fix(profound): fix import ordering and JSON formatting for CI lint * fix(profound): gate metrics mapping on current operation to prevent stale overrides * fix(profound): guard JSON.parse on filters, fix offset=0 falsy check, remove duplicate prompt_answers in FILTER_OPS * lint * fix(docs): fix import ordering and trailing newline for docs lint * fix(scripts): sort generated imports to match Biome's organizeImports order * fix(profound): use != null checks for limit param across all tools * fix(profound): flatten block output type to 'json' to pass block validation test * fix(profound): remove invalid 'required' field from block inputs (not part of ParamConfig) * fix(profound): rename tool files from kebab-case to snake_case for docs generator compatibility * lint * fix(docs): let biome auto-fix import order, revert custom sort in generator * fix(landing): fix import order in sim icon-mapping via biome * fix(scripts): match Biome's exact import sort order in docs generator * fix(generate-docs): produce Biome-compatible JSON output The generator wrote multi-line arrays for short string arrays (like tags) and omitted trailing newlines, causing Biome format check failures in CI. Post-process integrations.json to collapse short arrays onto single lines and add trailing newlines to both integrations.json and meta.json. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/docs/components/icons.tsx | 11 + apps/docs/components/ui/icon-mapping.ts | 2 + apps/docs/content/docs/en/tools/meta.json | 1 + apps/docs/content/docs/en/tools/profound.mdx | 626 ++++++++++++++++++ .../integrations/data/icon-mapping.ts | 2 + .../integrations/data/integrations.json | 115 ++++ apps/sim/blocks/blocks/profound.ts | 406 ++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 11 + apps/sim/tools/profound/bot_logs.ts | 137 ++++ apps/sim/tools/profound/bots_report.ts | 145 ++++ apps/sim/tools/profound/category_assets.ts | 84 +++ apps/sim/tools/profound/category_personas.ts | 98 +++ apps/sim/tools/profound/category_prompts.ts | 189 ++++++ apps/sim/tools/profound/category_tags.ts | 64 ++ apps/sim/tools/profound/category_topics.ts | 64 ++ apps/sim/tools/profound/citation_prompts.ts | 60 ++ apps/sim/tools/profound/citations_report.ts | 144 ++++ apps/sim/tools/profound/index.ts | 24 + apps/sim/tools/profound/list_assets.ts | 85 +++ apps/sim/tools/profound/list_categories.ts | 57 ++ apps/sim/tools/profound/list_domains.ts | 59 ++ apps/sim/tools/profound/list_models.ts | 57 ++ apps/sim/tools/profound/list_optimizations.ts | 104 +++ apps/sim/tools/profound/list_personas.ts | 96 +++ apps/sim/tools/profound/list_regions.ts | 57 ++ .../tools/profound/optimization_analysis.ts | 161 +++++ apps/sim/tools/profound/prompt_answers.ts | 141 ++++ apps/sim/tools/profound/prompt_volume.ts | 138 ++++ apps/sim/tools/profound/query_fanouts.ts | 143 ++++ apps/sim/tools/profound/raw_logs.ts | 137 ++++ apps/sim/tools/profound/referrals_report.ts | 146 ++++ apps/sim/tools/profound/sentiment_report.ts | 144 ++++ apps/sim/tools/profound/types.ts | 422 ++++++++++++ apps/sim/tools/profound/visibility_report.ts | 145 ++++ apps/sim/tools/registry.ts | 50 ++ scripts/generate-docs.ts | 34 +- 37 files changed, 4356 insertions(+), 5 deletions(-) create mode 100644 apps/docs/content/docs/en/tools/profound.mdx create mode 100644 apps/sim/blocks/blocks/profound.ts create mode 100644 apps/sim/tools/profound/bot_logs.ts create mode 100644 apps/sim/tools/profound/bots_report.ts create mode 100644 apps/sim/tools/profound/category_assets.ts create mode 100644 apps/sim/tools/profound/category_personas.ts create mode 100644 apps/sim/tools/profound/category_prompts.ts create mode 100644 apps/sim/tools/profound/category_tags.ts create mode 100644 apps/sim/tools/profound/category_topics.ts create mode 100644 apps/sim/tools/profound/citation_prompts.ts create mode 100644 apps/sim/tools/profound/citations_report.ts create mode 100644 apps/sim/tools/profound/index.ts create mode 100644 apps/sim/tools/profound/list_assets.ts create mode 100644 apps/sim/tools/profound/list_categories.ts create mode 100644 apps/sim/tools/profound/list_domains.ts create mode 100644 apps/sim/tools/profound/list_models.ts create mode 100644 apps/sim/tools/profound/list_optimizations.ts create mode 100644 apps/sim/tools/profound/list_personas.ts create mode 100644 apps/sim/tools/profound/list_regions.ts create mode 100644 apps/sim/tools/profound/optimization_analysis.ts create mode 100644 apps/sim/tools/profound/prompt_answers.ts create mode 100644 apps/sim/tools/profound/prompt_volume.ts create mode 100644 apps/sim/tools/profound/query_fanouts.ts create mode 100644 apps/sim/tools/profound/raw_logs.ts create mode 100644 apps/sim/tools/profound/referrals_report.ts create mode 100644 apps/sim/tools/profound/sentiment_report.ts create mode 100644 apps/sim/tools/profound/types.ts create mode 100644 apps/sim/tools/profound/visibility_report.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index b79a166e901..6f53db86f8b 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1285,6 +1285,17 @@ export function StartIcon(props: SVGProps) { ) } +export function ProfoundIcon(props: SVGProps) { + return ( + + + + ) +} + export function PineconeIcon(props: SVGProps) { return ( = { polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, + profound: ProfoundIcon, pulse_v2: PulseIcon, qdrant: QdrantIcon, quiver: QuiverIcon, diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index a52a54cf0f8..49ee064ffb1 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -121,6 +121,7 @@ "polymarket", "postgresql", "posthog", + "profound", "pulse", "qdrant", "quiver", diff --git a/apps/docs/content/docs/en/tools/profound.mdx b/apps/docs/content/docs/en/tools/profound.mdx new file mode 100644 index 00000000000..8f2cb0e83cd --- /dev/null +++ b/apps/docs/content/docs/en/tools/profound.mdx @@ -0,0 +1,626 @@ +--- +title: Profound +description: AI visibility and analytics with Profound +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Profound](https://tryprofound.com/) is an AI visibility and analytics platform that helps brands understand how they appear across AI-powered search engines, chatbots, and assistants. It tracks mentions, citations, sentiment, bot traffic, and referral patterns across platforms like ChatGPT, Perplexity, Google AI Overviews, and more. + +With the Profound integration in Sim, you can: + +- **Monitor AI Visibility**: Track share of voice, visibility scores, and mention counts across AI platforms for your brand and competitors. +- **Analyze Sentiment**: Measure how positively or negatively your brand is discussed in AI-generated responses. +- **Track Citations**: See which URLs are being cited by AI models and your citation share relative to competitors. +- **Monitor Bot Traffic**: Analyze AI crawler activity on your domain, including GPTBot, ClaudeBot, and other AI agents, with hourly granularity. +- **Track Referral Traffic**: Monitor human visits arriving from AI platforms to your website. +- **Explore Prompt Data**: Access raw prompt-answer pairs, query fanouts, and prompt volume trends across AI platforms. +- **Optimize Content**: Get AEO (Answer Engine Optimization) scores and actionable recommendations to improve how AI models reference your content. +- **Manage Categories & Assets**: List and explore your tracked categories, assets (brands), topics, tags, personas, and regions. + +These tools let your agents automate AI visibility monitoring, competitive intelligence, and content optimization workflows. To use the Profound integration, you'll need a Profound account with API access. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound. + + + +## Tools + +### `profound_list_categories` + +List all organization categories in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `categories` | json | List of organization categories | +| ↳ `id` | string | Category ID | +| ↳ `name` | string | Category name | + +### `profound_list_regions` + +List all organization regions in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `regions` | json | List of organization regions | +| ↳ `id` | string | Region ID \(UUID\) | +| ↳ `name` | string | Region name | + +### `profound_list_models` + +List all AI models/platforms tracked in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `models` | json | List of AI models/platforms | +| ↳ `id` | string | Model ID \(UUID\) | +| ↳ `name` | string | Model/platform name | + +### `profound_list_domains` + +List all organization domains in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `domains` | json | List of organization domains | +| ↳ `id` | string | Domain ID \(UUID\) | +| ↳ `name` | string | Domain name | +| ↳ `createdAt` | string | When the domain was added | + +### `profound_list_assets` + +List all organization assets (companies/brands) across all categories in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assets` | json | List of organization assets with category info | +| ↳ `id` | string | Asset ID | +| ↳ `name` | string | Asset/company name | +| ↳ `website` | string | Asset website URL | +| ↳ `alternateDomains` | json | Alternate domain names | +| ↳ `isOwned` | boolean | Whether this asset is owned by the organization | +| ↳ `createdAt` | string | When the asset was created | +| ↳ `logoUrl` | string | URL of the asset logo | +| ↳ `categoryId` | string | Category ID the asset belongs to | +| ↳ `categoryName` | string | Category name | + +### `profound_list_personas` + +List all organization personas across all categories in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `personas` | json | List of organization personas with profile details | +| ↳ `id` | string | Persona ID | +| ↳ `name` | string | Persona name | +| ↳ `categoryId` | string | Category ID | +| ↳ `categoryName` | string | Category name | +| ↳ `persona` | json | Persona profile with behavior, employment, and demographics | + +### `profound_category_topics` + +List topics for a specific category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `topics` | json | List of topics in the category | +| ↳ `id` | string | Topic ID \(UUID\) | +| ↳ `name` | string | Topic name | + +### `profound_category_tags` + +List tags for a specific category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | json | List of tags in the category | +| ↳ `id` | string | Tag ID \(UUID\) | +| ↳ `name` | string | Tag name | + +### `profound_category_prompts` + +List prompts for a specific category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | +| `limit` | number | No | Maximum number of results \(default 10000, max 10000\) | +| `cursor` | string | No | Pagination cursor from previous response | +| `orderDir` | string | No | Sort direction: asc or desc \(default desc\) | +| `promptType` | string | No | Comma-separated prompt types to filter: visibility, sentiment | +| `topicId` | string | No | Comma-separated topic IDs \(UUIDs\) to filter by | +| `tagId` | string | No | Comma-separated tag IDs \(UUIDs\) to filter by | +| `regionId` | string | No | Comma-separated region IDs \(UUIDs\) to filter by | +| `platformId` | string | No | Comma-separated platform IDs \(UUIDs\) to filter by | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of prompts | +| `nextCursor` | string | Cursor for next page of results | +| `prompts` | json | List of prompts | +| ↳ `id` | string | Prompt ID | +| ↳ `prompt` | string | Prompt text | +| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) | +| ↳ `topicId` | string | Topic ID | +| ↳ `topicName` | string | Topic name | +| ↳ `tags` | json | Associated tags | +| ↳ `regions` | json | Associated regions | +| ↳ `platforms` | json | Associated platforms | +| ↳ `createdAt` | string | When the prompt was created | + +### `profound_category_assets` + +List assets (companies/brands) for a specific category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assets` | json | List of assets in the category | +| ↳ `id` | string | Asset ID | +| ↳ `name` | string | Asset/company name | +| ↳ `website` | string | Website URL | +| ↳ `alternateDomains` | json | Alternate domain names | +| ↳ `isOwned` | boolean | Whether the asset is owned by the organization | +| ↳ `createdAt` | string | When the asset was created | +| ↳ `logoUrl` | string | URL of the asset logo | + +### `profound_category_personas` + +List personas for a specific category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `personas` | json | List of personas in the category | +| ↳ `id` | string | Persona ID | +| ↳ `name` | string | Persona name | +| ↳ `persona` | json | Persona profile with behavior, employment, and demographics | + +### `profound_visibility_report` + +Query AI visibility report for a category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) | +| `metrics` | string | Yes | Comma-separated metrics: share_of_voice, mentions_count, visibility_score, executions, average_position | +| `dimensions` | string | No | Comma-separated dimensions: date, region, topic, model, asset_name, prompt, tag, persona | +| `dateInterval` | string | No | Date interval: hour, day, week, month, year | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of rows in the report | +| `data` | json | Report data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values matching requested metrics order | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_sentiment_report` + +Query sentiment report for a category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) | +| `metrics` | string | Yes | Comma-separated metrics: positive, negative, occurrences | +| `dimensions` | string | No | Comma-separated dimensions: theme, date, region, topic, model, asset_name, tag, prompt, sentiment_type, persona | +| `dateInterval` | string | No | Date interval: hour, day, week, month, year | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of rows in the report | +| `data` | json | Report data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values matching requested metrics order | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_citations_report` + +Query citations report for a category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) | +| `metrics` | string | Yes | Comma-separated metrics: count, citation_share | +| `dimensions` | string | No | Comma-separated dimensions: hostname, path, date, region, topic, model, tag, prompt, url, root_domain, persona, citation_category | +| `dateInterval` | string | No | Date interval: hour, day, week, month, year | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"hostname","operator":"is","value":"example.com"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of rows in the report | +| `data` | json | Report data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values matching requested metrics order | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_query_fanouts` + +Query fanout report showing how AI models expand prompts into sub-queries in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) | +| `metrics` | string | Yes | Comma-separated metrics: fanouts_per_execution, total_fanouts, share | +| `dimensions` | string | No | Comma-separated dimensions: prompt, query, model, region, date | +| `dateInterval` | string | No | Date interval: hour, day, week, month, year | +| `filters` | string | No | JSON array of filter objects | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of rows in the report | +| `data` | json | Report data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values matching requested metrics order | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_prompt_answers` + +Get raw prompt answers data for a category in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `categoryId` | string | Yes | Category ID \(UUID\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"prompt_type","operator":"is","value":"visibility"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of answer rows | +| `data` | json | Raw prompt answer data | +| ↳ `prompt` | string | The prompt text | +| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) | +| ↳ `response` | string | AI model response text | +| ↳ `mentions` | json | Companies/assets mentioned in the response | +| ↳ `citations` | json | URLs cited in the response | +| ↳ `topic` | string | Topic name | +| ↳ `region` | string | Region name | +| ↳ `model` | string | AI model/platform name | +| ↳ `asset` | string | Asset name | +| ↳ `createdAt` | string | Timestamp when the answer was collected | + +### `profound_bots_report` + +Query bot traffic report with hourly granularity for a domain in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `domain` | string | Yes | Domain to query bot traffic for \(e.g. example.com\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now | +| `metrics` | string | Yes | Comma-separated metrics: count, citations, indexing, training, last_visit | +| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, bot_name, bot_provider, bot_type | +| `dateInterval` | string | No | Date interval: hour, day, week, month, year | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of rows in the report | +| `data` | json | Report data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values matching requested metrics order | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_referrals_report` + +Query human referral traffic report with hourly granularity for a domain in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `domain` | string | Yes | Domain to query referral traffic for \(e.g. example.com\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now | +| `metrics` | string | Yes | Comma-separated metrics: visits, last_visit | +| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, referral_source, referral_type | +| `dateInterval` | string | No | Date interval: hour, day, week, month, year | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"referral_source","operator":"is","value":"openai"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of rows in the report | +| `data` | json | Report data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values matching requested metrics order | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_raw_logs` + +Get raw traffic logs with filters for a domain in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `domain` | string | Yes | Domain to query logs for \(e.g. example.com\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now | +| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"path","operator":"contains","value":"/blog"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of log entries | +| `data` | json | Log data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values \(count\) | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_bot_logs` + +Get identified bot visit logs with filters for a domain in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `domain` | string | Yes | Domain to query bot logs for \(e.g. example.com\) | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now | +| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params, bot_name, bot_provider, bot_types | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of bot log entries | +| `data` | json | Bot log data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values \(count\) | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_list_optimizations` + +List content optimization entries for an asset in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `assetId` | string | Yes | Asset ID \(UUID\) | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | +| `offset` | number | No | Offset for pagination \(default 0\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of optimization entries | +| `optimizations` | json | List of content optimization entries | +| ↳ `id` | string | Optimization ID \(UUID\) | +| ↳ `title` | string | Content title | +| ↳ `createdAt` | string | When the optimization was created | +| ↳ `extractedInput` | string | Extracted input text | +| ↳ `type` | string | Content type: file, text, or url | +| ↳ `status` | string | Optimization status | + +### `profound_optimization_analysis` + +Get detailed content optimization analysis for a specific content item in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `assetId` | string | Yes | Asset ID \(UUID\) | +| `contentId` | string | Yes | Content/optimization ID \(UUID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | json | The analyzed content | +| ↳ `format` | string | Content format: markdown or html | +| ↳ `value` | string | Content text | +| `aeoContentScore` | json | AEO content score with target zone | +| ↳ `value` | number | AEO score value | +| ↳ `targetZone` | json | Target zone range | +| ↳ `low` | number | Low end of target range | +| ↳ `high` | number | High end of target range | +| `analysis` | json | Analysis breakdown by category | +| ↳ `breakdown` | json | Array of scoring breakdowns | +| ↳ `title` | string | Category title | +| ↳ `weight` | number | Category weight | +| ↳ `score` | number | Category score | +| `recommendations` | json | Content optimization recommendations | +| ↳ `title` | string | Recommendation title | +| ↳ `status` | string | Status: done or pending | +| ↳ `impact` | json | Impact details with section and score | +| ↳ `suggestion` | json | Suggestion text and rationale | +| ↳ `text` | string | Suggestion text | +| ↳ `rationale` | string | Why this recommendation matters | + +### `profound_prompt_volume` + +Query prompt volume data to understand search demand across AI platforms in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) | +| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) | +| `metrics` | string | Yes | Comma-separated metrics: volume, change | +| `dimensions` | string | No | Comma-separated dimensions: keyword, date, platform, country_code, matching_type, frequency | +| `dateInterval` | string | No | Date interval: hour, day, week, month, year | +| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"keyword","operator":"contains","value":"best"\}\] | +| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalRows` | number | Total number of rows in the report | +| `data` | json | Volume data rows with metrics and dimension values | +| ↳ `metrics` | json | Array of metric values matching requested metrics order | +| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order | + +### `profound_citation_prompts` + +Get prompts that cite a specific domain across AI platforms in Profound + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Profound API Key | +| `inputDomain` | string | Yes | Domain to look up citations for \(e.g. ramp.com\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `data` | json | Citation prompt data for the queried domain | + + diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index 1d77fc88889..841cda375b3 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -126,6 +126,7 @@ import { PolymarketIcon, PostgresIcon, PosthogIcon, + ProfoundIcon, PulseIcon, QdrantIcon, QuiverIcon, @@ -302,6 +303,7 @@ export const blockTypeToIconMap: Record = { polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, + profound: ProfoundIcon, pulse_v2: PulseIcon, qdrant: QdrantIcon, quiver: QuiverIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 95d2ef9ea29..2816d7c4ee0 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -8611,6 +8611,121 @@ "integrationType": "analytics", "tags": ["data-analytics", "monitoring"] }, + { + "type": "profound", + "slug": "profound", + "name": "Profound", + "description": "AI visibility and analytics with Profound", + "longDescription": "Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.", + "bgColor": "#1A1A2E", + "iconName": "ProfoundIcon", + "docsUrl": "https://docs.sim.ai/tools/profound", + "operations": [ + { + "name": "List Categories", + "description": "List all organization categories in Profound" + }, + { + "name": "List Regions", + "description": "List all organization regions in Profound" + }, + { + "name": "List Models", + "description": "List all AI models/platforms tracked in Profound" + }, + { + "name": "List Domains", + "description": "List all organization domains in Profound" + }, + { + "name": "List Assets", + "description": "List all organization assets (companies/brands) across all categories in Profound" + }, + { + "name": "List Personas", + "description": "List all organization personas across all categories in Profound" + }, + { + "name": "Category Topics", + "description": "List topics for a specific category in Profound" + }, + { + "name": "Category Tags", + "description": "List tags for a specific category in Profound" + }, + { + "name": "Category Prompts", + "description": "List prompts for a specific category in Profound" + }, + { + "name": "Category Assets", + "description": "List assets (companies/brands) for a specific category in Profound" + }, + { + "name": "Category Personas", + "description": "List personas for a specific category in Profound" + }, + { + "name": "Visibility Report", + "description": "Query AI visibility report for a category in Profound" + }, + { + "name": "Sentiment Report", + "description": "Query sentiment report for a category in Profound" + }, + { + "name": "Citations Report", + "description": "Query citations report for a category in Profound" + }, + { + "name": "Query Fanouts", + "description": "Query fanout report showing how AI models expand prompts into sub-queries in Profound" + }, + { + "name": "Prompt Answers", + "description": "Get raw prompt answers data for a category in Profound" + }, + { + "name": "Bots Report", + "description": "Query bot traffic report with hourly granularity for a domain in Profound" + }, + { + "name": "Referrals Report", + "description": "Query human referral traffic report with hourly granularity for a domain in Profound" + }, + { + "name": "Raw Logs", + "description": "Get raw traffic logs with filters for a domain in Profound" + }, + { + "name": "Bot Logs", + "description": "Get identified bot visit logs with filters for a domain in Profound" + }, + { + "name": "List Optimizations", + "description": "List content optimization entries for an asset in Profound" + }, + { + "name": "Optimization Analysis", + "description": "Get detailed content optimization analysis for a specific content item in Profound" + }, + { + "name": "Prompt Volume", + "description": "Query prompt volume data to understand search demand across AI platforms in Profound" + }, + { + "name": "Citation Prompts", + "description": "Get prompts that cite a specific domain across AI platforms in Profound" + } + ], + "operationCount": 24, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "analytics", + "tags": ["seo", "data-analytics"] + }, { "type": "pulse_v2", "slug": "pulse", diff --git a/apps/sim/blocks/blocks/profound.ts b/apps/sim/blocks/blocks/profound.ts new file mode 100644 index 00000000000..47bc3079440 --- /dev/null +++ b/apps/sim/blocks/blocks/profound.ts @@ -0,0 +1,406 @@ +import { ProfoundIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' + +const CATEGORY_REPORT_OPS = [ + 'visibility_report', + 'sentiment_report', + 'citations_report', + 'prompt_answers', + 'query_fanouts', +] as const + +const DOMAIN_REPORT_OPS = ['bots_report', 'referrals_report', 'raw_logs', 'bot_logs'] as const + +const ALL_REPORT_OPS = [...CATEGORY_REPORT_OPS, ...DOMAIN_REPORT_OPS] as const + +const CATEGORY_ID_OPS = [ + ...CATEGORY_REPORT_OPS, + 'category_topics', + 'category_tags', + 'category_prompts', + 'category_assets', + 'category_personas', +] as const + +const DATE_REQUIRED_CATEGORY_OPS = [ + 'visibility_report', + 'sentiment_report', + 'citations_report', + 'prompt_answers', + 'query_fanouts', + 'prompt_volume', +] as const + +const DATE_REQUIRED_ALL_OPS = [...DATE_REQUIRED_CATEGORY_OPS, ...DOMAIN_REPORT_OPS] as const + +const METRICS_REPORT_OPS = [ + 'visibility_report', + 'sentiment_report', + 'citations_report', + 'bots_report', + 'referrals_report', + 'query_fanouts', + 'prompt_volume', +] as const + +const DIMENSION_OPS = [ + 'visibility_report', + 'sentiment_report', + 'citations_report', + 'bots_report', + 'referrals_report', + 'query_fanouts', + 'raw_logs', + 'bot_logs', + 'prompt_volume', +] as const + +const FILTER_OPS = [...ALL_REPORT_OPS, 'prompt_volume'] as const + +export const ProfoundBlock: BlockConfig = { + type: 'profound', + name: 'Profound', + description: 'AI visibility and analytics with Profound', + longDescription: + 'Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.', + docsLink: 'https://docs.sim.ai/tools/profound', + category: 'tools', + integrationType: IntegrationType.Analytics, + tags: ['seo', 'data-analytics'], + bgColor: '#1A1A2E', + icon: ProfoundIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Categories', id: 'list_categories' }, + { label: 'List Regions', id: 'list_regions' }, + { label: 'List Models', id: 'list_models' }, + { label: 'List Domains', id: 'list_domains' }, + { label: 'List Assets', id: 'list_assets' }, + { label: 'List Personas', id: 'list_personas' }, + { label: 'Category Topics', id: 'category_topics' }, + { label: 'Category Tags', id: 'category_tags' }, + { label: 'Category Prompts', id: 'category_prompts' }, + { label: 'Category Assets', id: 'category_assets' }, + { label: 'Category Personas', id: 'category_personas' }, + { label: 'Visibility Report', id: 'visibility_report' }, + { label: 'Sentiment Report', id: 'sentiment_report' }, + { label: 'Citations Report', id: 'citations_report' }, + { label: 'Query Fanouts', id: 'query_fanouts' }, + { label: 'Prompt Answers', id: 'prompt_answers' }, + { label: 'Bots Report', id: 'bots_report' }, + { label: 'Referrals Report', id: 'referrals_report' }, + { label: 'Raw Logs', id: 'raw_logs' }, + { label: 'Bot Logs', id: 'bot_logs' }, + { label: 'List Optimizations', id: 'list_optimizations' }, + { label: 'Optimization Analysis', id: 'optimization_analysis' }, + { label: 'Prompt Volume', id: 'prompt_volume' }, + { label: 'Citation Prompts', id: 'citation_prompts' }, + ], + value: () => 'visibility_report', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Profound API key', + required: true, + password: true, + }, + + // Category ID - for category-based operations + { + id: 'categoryId', + title: 'Category ID', + type: 'short-input', + placeholder: 'Category UUID', + required: { field: 'operation', value: [...CATEGORY_ID_OPS] }, + condition: { field: 'operation', value: [...CATEGORY_ID_OPS] }, + }, + + // Domain - for domain-based operations + { + id: 'domain', + title: 'Domain', + type: 'short-input', + placeholder: 'e.g. example.com', + required: { field: 'operation', value: [...DOMAIN_REPORT_OPS] }, + condition: { field: 'operation', value: [...DOMAIN_REPORT_OPS] }, + }, + + // Input domain - for citation prompts + { + id: 'inputDomain', + title: 'Domain', + type: 'short-input', + placeholder: 'e.g. ramp.com', + required: { field: 'operation', value: 'citation_prompts' }, + condition: { field: 'operation', value: 'citation_prompts' }, + }, + + // Asset ID - for content optimization + { + id: 'assetId', + title: 'Asset ID', + type: 'short-input', + placeholder: 'Asset UUID', + required: { field: 'operation', value: ['list_optimizations', 'optimization_analysis'] }, + condition: { field: 'operation', value: ['list_optimizations', 'optimization_analysis'] }, + }, + + // Content ID - for optimization analysis + { + id: 'contentId', + title: 'Content ID', + type: 'short-input', + placeholder: 'Content/optimization UUID', + required: { field: 'operation', value: 'optimization_analysis' }, + condition: { field: 'operation', value: 'optimization_analysis' }, + }, + + // Date fields + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + required: { field: 'operation', value: [...DATE_REQUIRED_ALL_OPS] }, + condition: { field: 'operation', value: [...DATE_REQUIRED_ALL_OPS] }, + wandConfig: { + enabled: true, + prompt: 'Generate a date in YYYY-MM-DD format. Return ONLY the date string.', + generationType: 'timestamp', + }, + }, + { + id: 'endDate', + title: 'End Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + required: { field: 'operation', value: [...DATE_REQUIRED_CATEGORY_OPS] }, + condition: { field: 'operation', value: [...DATE_REQUIRED_ALL_OPS] }, + wandConfig: { + enabled: true, + prompt: 'Generate a date in YYYY-MM-DD format. Return ONLY the date string.', + generationType: 'timestamp', + }, + }, + + // Per-operation metrics fields + { + id: 'visibilityMetrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'share_of_voice, visibility_score, mentions_count', + required: { field: 'operation', value: 'visibility_report' }, + condition: { field: 'operation', value: 'visibility_report' }, + }, + { + id: 'sentimentMetrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'positive, negative, occurrences', + required: { field: 'operation', value: 'sentiment_report' }, + condition: { field: 'operation', value: 'sentiment_report' }, + }, + { + id: 'citationsMetrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'count, citation_share', + required: { field: 'operation', value: 'citations_report' }, + condition: { field: 'operation', value: 'citations_report' }, + }, + { + id: 'botsMetrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'count, citations, indexing, training', + required: { field: 'operation', value: 'bots_report' }, + condition: { field: 'operation', value: 'bots_report' }, + }, + { + id: 'referralsMetrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'visits, last_visit', + required: { field: 'operation', value: 'referrals_report' }, + condition: { field: 'operation', value: 'referrals_report' }, + }, + { + id: 'fanoutsMetrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'fanouts_per_execution, total_fanouts, share', + required: { field: 'operation', value: 'query_fanouts' }, + condition: { field: 'operation', value: 'query_fanouts' }, + }, + { + id: 'volumeMetrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'volume, change', + required: { field: 'operation', value: 'prompt_volume' }, + condition: { field: 'operation', value: 'prompt_volume' }, + }, + + // Advanced fields + { + id: 'dimensions', + title: 'Dimensions', + type: 'short-input', + placeholder: 'e.g. date, asset_name, model', + condition: { field: 'operation', value: [...DIMENSION_OPS] }, + mode: 'advanced', + }, + { + id: 'dateInterval', + title: 'Date Interval', + type: 'dropdown', + options: [ + { label: 'Day', id: 'day' }, + { label: 'Hour', id: 'hour' }, + { label: 'Week', id: 'week' }, + { label: 'Month', id: 'month' }, + { label: 'Year', id: 'year' }, + ], + condition: { field: 'operation', value: [...METRICS_REPORT_OPS] }, + mode: 'advanced', + }, + { + id: 'filters', + title: 'Filters', + type: 'long-input', + placeholder: '[{"field":"asset_name","operator":"is","value":"Company"}]', + condition: { field: 'operation', value: [...FILTER_OPS] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of filter objects. Each object has "field", "operator", and "value" keys. Return ONLY valid JSON.', + generationType: 'json-object', + }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '10000', + condition: { + field: 'operation', + value: [...FILTER_OPS, 'category_prompts', 'list_optimizations'], + }, + mode: 'advanced', + }, + + // Category prompts specific fields + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Pagination cursor from previous response', + condition: { field: 'operation', value: 'category_prompts' }, + mode: 'advanced', + }, + { + id: 'promptType', + title: 'Prompt Type', + type: 'short-input', + placeholder: 'visibility, sentiment', + condition: { field: 'operation', value: 'category_prompts' }, + mode: 'advanced', + }, + + // Optimization list specific + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_optimizations' }, + mode: 'advanced', + }, + ], + + tools: { + access: [ + 'profound_list_categories', + 'profound_list_regions', + 'profound_list_models', + 'profound_list_domains', + 'profound_list_assets', + 'profound_list_personas', + 'profound_category_topics', + 'profound_category_tags', + 'profound_category_prompts', + 'profound_category_assets', + 'profound_category_personas', + 'profound_visibility_report', + 'profound_sentiment_report', + 'profound_citations_report', + 'profound_query_fanouts', + 'profound_prompt_answers', + 'profound_bots_report', + 'profound_referrals_report', + 'profound_raw_logs', + 'profound_bot_logs', + 'profound_list_optimizations', + 'profound_optimization_analysis', + 'profound_prompt_volume', + 'profound_citation_prompts', + ], + config: { + tool: (params) => `profound_${params.operation}`, + params: (params) => { + const result: Record = {} + const metricsMap: Record = { + visibility_report: 'visibilityMetrics', + sentiment_report: 'sentimentMetrics', + citations_report: 'citationsMetrics', + bots_report: 'botsMetrics', + referrals_report: 'referralsMetrics', + query_fanouts: 'fanoutsMetrics', + prompt_volume: 'volumeMetrics', + } + const metricsField = metricsMap[params.operation as string] + if (metricsField && params[metricsField]) { + result.metrics = params[metricsField] + } + if (params.limit != null) result.limit = Number(params.limit) + if (params.offset != null) result.offset = Number(params.offset) + return result + }, + }, + }, + + inputs: { + apiKey: { type: 'string' }, + categoryId: { type: 'string' }, + domain: { type: 'string' }, + inputDomain: { type: 'string' }, + assetId: { type: 'string' }, + contentId: { type: 'string' }, + startDate: { type: 'string' }, + endDate: { type: 'string' }, + metrics: { type: 'string' }, + dimensions: { type: 'string' }, + dateInterval: { type: 'string' }, + filters: { type: 'string' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + cursor: { type: 'string' }, + promptType: { type: 'string' }, + }, + + outputs: { + response: { + type: 'json', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index ff9fd2dc2c0..1461cd58a60 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -137,6 +137,7 @@ import { PipedriveBlock } from '@/blocks/blocks/pipedrive' import { PolymarketBlock } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock } from '@/blocks/blocks/postgresql' import { PostHogBlock } from '@/blocks/blocks/posthog' +import { ProfoundBlock } from '@/blocks/blocks/profound' import { PulseBlock, PulseV2Block } from '@/blocks/blocks/pulse' import { QdrantBlock } from '@/blocks/blocks/qdrant' import { QuiverBlock } from '@/blocks/blocks/quiver' @@ -357,6 +358,7 @@ export const registry: Record = { perplexity: PerplexityBlock, pinecone: PineconeBlock, pipedrive: PipedriveBlock, + profound: ProfoundBlock, polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, posthog: PostHogBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index b79a166e901..6f53db86f8b 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1285,6 +1285,17 @@ export function StartIcon(props: SVGProps) { ) } +export function ProfoundIcon(props: SVGProps) { + return ( + + + + ) +} + export function PineconeIcon(props: SVGProps) { return ( = { + id: 'profound_bot_logs', + name: 'Profound Bot Logs', + description: 'Get identified bot visit logs with filters for a domain in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Domain to query bot logs for (e.g. example.com)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601). Defaults to now', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params, bot_name, bot_provider, bot_types', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"bot_name","operator":"is","value":"GPTBot"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/logs/raw/bots', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + domain: params.domain, + start_date: params.startDate, + metrics: ['count'], + } + if (params.endDate) { + body.end_date = params.endDate + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to get bot logs') + } + if (Array.isArray(data)) { + return { + success: true, + output: { + totalRows: data.length, + data: data.map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of bot log entries', + }, + data: { + type: 'json', + description: 'Bot log data rows with metrics and dimension values', + properties: { + metrics: { type: 'json', description: 'Array of metric values (count)' }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/bots_report.ts b/apps/sim/tools/profound/bots_report.ts new file mode 100644 index 00000000000..786f3211678 --- /dev/null +++ b/apps/sim/tools/profound/bots_report.ts @@ -0,0 +1,145 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundBotsReportParams, ProfoundBotsReportResponse } from './types' + +export const profoundBotsReportTool: ToolConfig< + ProfoundBotsReportParams, + ProfoundBotsReportResponse +> = { + id: 'profound_bots_report', + name: 'Profound Bots Report', + description: 'Query bot traffic report with hourly granularity for a domain in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Domain to query bot traffic for (e.g. example.com)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601). Defaults to now', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated metrics: count, citations, indexing, training, last_visit', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated dimensions: date, hour, path, bot_name, bot_provider, bot_type', + }, + dateInterval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date interval: hour, day, week, month, year', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"bot_name","operator":"is","value":"GPTBot"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v2/reports/bots', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + domain: params.domain, + start_date: params.startDate, + metrics: params.metrics.split(',').map((m) => m.trim()), + } + if (params.endDate) { + body.end_date = params.endDate + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.dateInterval) { + body.date_interval = params.dateInterval + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to query bots report') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of rows in the report', + }, + data: { + type: 'json', + description: 'Report data rows with metrics and dimension values', + properties: { + metrics: { + type: 'json', + description: 'Array of metric values matching requested metrics order', + }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/category_assets.ts b/apps/sim/tools/profound/category_assets.ts new file mode 100644 index 00000000000..ae53f6c0fc5 --- /dev/null +++ b/apps/sim/tools/profound/category_assets.ts @@ -0,0 +1,84 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundCategoryAssetsParams, ProfoundCategoryAssetsResponse } from './types' + +export const profoundCategoryAssetsTool: ToolConfig< + ProfoundCategoryAssetsParams, + ProfoundCategoryAssetsResponse +> = { + id: 'profound_category_assets', + name: 'Profound Category Assets', + description: 'List assets (companies/brands) for a specific category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + }, + + request: { + url: (params) => + `https://api.tryprofound.com/v1/org/categories/${encodeURIComponent(params.categoryId)}/assets`, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list category assets') + } + return { + success: true, + output: { + assets: (data ?? []).map( + (item: { + id: string + name: string + website: string + alternate_domains: string[] | null + is_owned: boolean + created_at: string + logo_url: string + }) => ({ + id: item.id ?? null, + name: item.name ?? null, + website: item.website ?? null, + alternateDomains: item.alternate_domains ?? null, + isOwned: item.is_owned ?? false, + createdAt: item.created_at ?? null, + logoUrl: item.logo_url ?? null, + }) + ), + }, + } + }, + + outputs: { + assets: { + type: 'json', + description: 'List of assets in the category', + properties: { + id: { type: 'string', description: 'Asset ID' }, + name: { type: 'string', description: 'Asset/company name' }, + website: { type: 'string', description: 'Website URL' }, + alternateDomains: { type: 'json', description: 'Alternate domain names' }, + isOwned: { type: 'boolean', description: 'Whether the asset is owned by the organization' }, + createdAt: { type: 'string', description: 'When the asset was created' }, + logoUrl: { type: 'string', description: 'URL of the asset logo' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/category_personas.ts b/apps/sim/tools/profound/category_personas.ts new file mode 100644 index 00000000000..5a9ec046d4d --- /dev/null +++ b/apps/sim/tools/profound/category_personas.ts @@ -0,0 +1,98 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundCategoryPersonasParams, ProfoundCategoryPersonasResponse } from './types' + +export const profoundCategoryPersonasTool: ToolConfig< + ProfoundCategoryPersonasParams, + ProfoundCategoryPersonasResponse +> = { + id: 'profound_category_personas', + name: 'Profound Category Personas', + description: 'List personas for a specific category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + }, + + request: { + url: (params) => + `https://api.tryprofound.com/v1/org/categories/${encodeURIComponent(params.categoryId)}/personas`, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list category personas') + } + return { + success: true, + output: { + personas: (data.data ?? []).map( + (item: { + id: string + name: string + persona: { + behavior: { painPoints: string | null; motivations: string | null } + employment: { + industry: string[] + jobTitle: string[] + companySize: string[] + roleSeniority: string[] + } + demographics: { ageRange: string[] } + } + }) => ({ + id: item.id ?? null, + name: item.name ?? null, + persona: { + behavior: { + painPoints: item.persona?.behavior?.painPoints ?? null, + motivations: item.persona?.behavior?.motivations ?? null, + }, + employment: { + industry: item.persona?.employment?.industry ?? [], + jobTitle: item.persona?.employment?.jobTitle ?? [], + companySize: item.persona?.employment?.companySize ?? [], + roleSeniority: item.persona?.employment?.roleSeniority ?? [], + }, + demographics: { + ageRange: item.persona?.demographics?.ageRange ?? [], + }, + }, + }) + ), + }, + } + }, + + outputs: { + personas: { + type: 'json', + description: 'List of personas in the category', + properties: { + id: { type: 'string', description: 'Persona ID' }, + name: { type: 'string', description: 'Persona name' }, + persona: { + type: 'json', + description: 'Persona profile with behavior, employment, and demographics', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/category_prompts.ts b/apps/sim/tools/profound/category_prompts.ts new file mode 100644 index 00000000000..7ac031164c3 --- /dev/null +++ b/apps/sim/tools/profound/category_prompts.ts @@ -0,0 +1,189 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundCategoryPromptsParams, ProfoundCategoryPromptsResponse } from './types' + +export const profoundCategoryPromptsTool: ToolConfig< + ProfoundCategoryPromptsParams, + ProfoundCategoryPromptsResponse +> = { + id: 'profound_category_prompts', + name: 'Profound Category Prompts', + description: 'List prompts for a specific category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 10000)', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from previous response', + }, + orderDir: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort direction: asc or desc (default desc)', + }, + promptType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated prompt types to filter: visibility, sentiment', + }, + topicId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated topic IDs (UUIDs) to filter by', + }, + tagId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated tag IDs (UUIDs) to filter by', + }, + regionId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated region IDs (UUIDs) to filter by', + }, + platformId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated platform IDs (UUIDs) to filter by', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.tryprofound.com/v1/org/categories/${encodeURIComponent(params.categoryId)}/prompts` + ) + if (params.limit != null) url.searchParams.set('limit', String(params.limit)) + if (params.cursor) url.searchParams.set('cursor', params.cursor) + if (params.orderDir) url.searchParams.set('order_dir', params.orderDir) + if (params.promptType) { + for (const pt of params.promptType.split(',').map((s) => s.trim())) { + url.searchParams.append('prompt_type', pt) + } + } + if (params.topicId) { + for (const tid of params.topicId.split(',').map((s) => s.trim())) { + url.searchParams.append('topic_id', tid) + } + } + if (params.tagId) { + for (const tid of params.tagId.split(',').map((s) => s.trim())) { + url.searchParams.append('tag_id', tid) + } + } + if (params.regionId) { + for (const rid of params.regionId.split(',').map((s) => s.trim())) { + url.searchParams.append('region_id', rid) + } + } + if (params.platformId) { + for (const pid of params.platformId.split(',').map((s) => s.trim())) { + url.searchParams.append('platform_id', pid) + } + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list category prompts') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + nextCursor: data.info?.next_cursor ?? null, + prompts: (data.data ?? []).map( + (item: { + id: string + prompt: string + prompt_type: string + topic: { id: string; name: string } + tags: Array<{ id: string; name: string }> + regions: Array<{ id: string; name: string }> + platforms: Array<{ id: string; name: string }> + created_at: string + }) => ({ + id: item.id ?? null, + prompt: item.prompt ?? null, + promptType: item.prompt_type ?? null, + topicId: item.topic?.id ?? null, + topicName: item.topic?.name ?? null, + tags: (item.tags ?? []).map((t: { id: string; name: string }) => ({ + id: t.id ?? null, + name: t.name ?? null, + })), + regions: (item.regions ?? []).map((r: { id: string; name: string }) => ({ + id: r.id ?? null, + name: r.name ?? null, + })), + platforms: (item.platforms ?? []).map((p: { id: string; name: string }) => ({ + id: p.id ?? null, + name: p.name ?? null, + })), + createdAt: item.created_at ?? null, + }) + ), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of prompts', + }, + nextCursor: { + type: 'string', + description: 'Cursor for next page of results', + optional: true, + }, + prompts: { + type: 'json', + description: 'List of prompts', + properties: { + id: { type: 'string', description: 'Prompt ID' }, + prompt: { type: 'string', description: 'Prompt text' }, + promptType: { type: 'string', description: 'Prompt type (visibility or sentiment)' }, + topicId: { type: 'string', description: 'Topic ID' }, + topicName: { type: 'string', description: 'Topic name' }, + tags: { type: 'json', description: 'Associated tags' }, + regions: { type: 'json', description: 'Associated regions' }, + platforms: { type: 'json', description: 'Associated platforms' }, + createdAt: { type: 'string', description: 'When the prompt was created' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/category_tags.ts b/apps/sim/tools/profound/category_tags.ts new file mode 100644 index 00000000000..138e5f21351 --- /dev/null +++ b/apps/sim/tools/profound/category_tags.ts @@ -0,0 +1,64 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundCategoryTagsParams, ProfoundCategoryTagsResponse } from './types' + +export const profoundCategoryTagsTool: ToolConfig< + ProfoundCategoryTagsParams, + ProfoundCategoryTagsResponse +> = { + id: 'profound_category_tags', + name: 'Profound Category Tags', + description: 'List tags for a specific category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + }, + + request: { + url: (params) => + `https://api.tryprofound.com/v1/org/categories/${encodeURIComponent(params.categoryId)}/tags`, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list category tags') + } + return { + success: true, + output: { + tags: (data ?? []).map((item: { id: string; name: string }) => ({ + id: item.id ?? null, + name: item.name ?? null, + })), + }, + } + }, + + outputs: { + tags: { + type: 'json', + description: 'List of tags in the category', + properties: { + id: { type: 'string', description: 'Tag ID (UUID)' }, + name: { type: 'string', description: 'Tag name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/category_topics.ts b/apps/sim/tools/profound/category_topics.ts new file mode 100644 index 00000000000..e39d51ffb66 --- /dev/null +++ b/apps/sim/tools/profound/category_topics.ts @@ -0,0 +1,64 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundCategoryTopicsParams, ProfoundCategoryTopicsResponse } from './types' + +export const profoundCategoryTopicsTool: ToolConfig< + ProfoundCategoryTopicsParams, + ProfoundCategoryTopicsResponse +> = { + id: 'profound_category_topics', + name: 'Profound Category Topics', + description: 'List topics for a specific category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + }, + + request: { + url: (params) => + `https://api.tryprofound.com/v1/org/categories/${encodeURIComponent(params.categoryId)}/topics`, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list category topics') + } + return { + success: true, + output: { + topics: (data ?? []).map((item: { id: string; name: string }) => ({ + id: item.id ?? null, + name: item.name ?? null, + })), + }, + } + }, + + outputs: { + topics: { + type: 'json', + description: 'List of topics in the category', + properties: { + id: { type: 'string', description: 'Topic ID (UUID)' }, + name: { type: 'string', description: 'Topic name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/citation_prompts.ts b/apps/sim/tools/profound/citation_prompts.ts new file mode 100644 index 00000000000..31b02b68677 --- /dev/null +++ b/apps/sim/tools/profound/citation_prompts.ts @@ -0,0 +1,60 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundCitationPromptsParams, ProfoundCitationPromptsResponse } from './types' + +export const profoundCitationPromptsTool: ToolConfig< + ProfoundCitationPromptsParams, + ProfoundCitationPromptsResponse +> = { + id: 'profound_citation_prompts', + name: 'Profound Citation Prompts', + description: 'Get prompts that cite a specific domain across AI platforms in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + inputDomain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Domain to look up citations for (e.g. ramp.com)', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.tryprofound.com/v1/prompt-volumes/citation-prompts') + url.searchParams.set('input_domain', params.inputDomain) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to get citation prompts') + } + return { + success: true, + output: { + data: data ?? null, + }, + } + }, + + outputs: { + data: { + type: 'json', + description: 'Citation prompt data for the queried domain', + }, + }, +} diff --git a/apps/sim/tools/profound/citations_report.ts b/apps/sim/tools/profound/citations_report.ts new file mode 100644 index 00000000000..dc3b83b76bd --- /dev/null +++ b/apps/sim/tools/profound/citations_report.ts @@ -0,0 +1,144 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundCitationsReportParams, ProfoundCitationsReportResponse } from './types' + +export const profoundCitationsReportTool: ToolConfig< + ProfoundCitationsReportParams, + ProfoundCitationsReportResponse +> = { + id: 'profound_citations_report', + name: 'Profound Citations Report', + description: 'Query citations report for a category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601)', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated metrics: count, citation_share', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated dimensions: hostname, path, date, region, topic, model, tag, prompt, url, root_domain, persona, citation_category', + }, + dateInterval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date interval: hour, day, week, month, year', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"hostname","operator":"is","value":"example.com"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/reports/citations', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + category_id: params.categoryId, + start_date: params.startDate, + end_date: params.endDate, + metrics: params.metrics.split(',').map((m) => m.trim()), + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.dateInterval) { + body.date_interval = params.dateInterval + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to query citations report') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of rows in the report', + }, + data: { + type: 'json', + description: 'Report data rows with metrics and dimension values', + properties: { + metrics: { + type: 'json', + description: 'Array of metric values matching requested metrics order', + }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/index.ts b/apps/sim/tools/profound/index.ts new file mode 100644 index 00000000000..7c4ef058d79 --- /dev/null +++ b/apps/sim/tools/profound/index.ts @@ -0,0 +1,24 @@ +export { profoundBotLogsTool } from './bot_logs' +export { profoundBotsReportTool } from './bots_report' +export { profoundCategoryAssetsTool } from './category_assets' +export { profoundCategoryPersonasTool } from './category_personas' +export { profoundCategoryPromptsTool } from './category_prompts' +export { profoundCategoryTagsTool } from './category_tags' +export { profoundCategoryTopicsTool } from './category_topics' +export { profoundCitationPromptsTool } from './citation_prompts' +export { profoundCitationsReportTool } from './citations_report' +export { profoundListAssetsTool } from './list_assets' +export { profoundListCategoriesTool } from './list_categories' +export { profoundListDomainsTool } from './list_domains' +export { profoundListModelsTool } from './list_models' +export { profoundListOptimizationsTool } from './list_optimizations' +export { profoundListPersonasTool } from './list_personas' +export { profoundListRegionsTool } from './list_regions' +export { profoundOptimizationAnalysisTool } from './optimization_analysis' +export { profoundPromptAnswersTool } from './prompt_answers' +export { profoundPromptVolumeTool } from './prompt_volume' +export { profoundQueryFanoutsTool } from './query_fanouts' +export { profoundRawLogsTool } from './raw_logs' +export { profoundReferralsReportTool } from './referrals_report' +export { profoundSentimentReportTool } from './sentiment_report' +export { profoundVisibilityReportTool } from './visibility_report' diff --git a/apps/sim/tools/profound/list_assets.ts b/apps/sim/tools/profound/list_assets.ts new file mode 100644 index 00000000000..b528a9c013b --- /dev/null +++ b/apps/sim/tools/profound/list_assets.ts @@ -0,0 +1,85 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundListAssetsParams, ProfoundListAssetsResponse } from './types' + +export const profoundListAssetsTool: ToolConfig< + ProfoundListAssetsParams, + ProfoundListAssetsResponse +> = { + id: 'profound_list_assets', + name: 'Profound List Assets', + description: 'List all organization assets (companies/brands) across all categories in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/org/assets', + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list assets') + } + return { + success: true, + output: { + assets: (data.data ?? []).map( + (item: { + id: string + name: string + website: string + alternate_domains: string[] | null + is_owned: boolean + created_at: string + logo_url: string + category: { id: string; name: string } + }) => ({ + id: item.id ?? null, + name: item.name ?? null, + website: item.website ?? null, + alternateDomains: item.alternate_domains ?? null, + isOwned: item.is_owned ?? false, + createdAt: item.created_at ?? null, + logoUrl: item.logo_url ?? null, + categoryId: item.category?.id ?? null, + categoryName: item.category?.name ?? null, + }) + ), + }, + } + }, + + outputs: { + assets: { + type: 'json', + description: 'List of organization assets with category info', + properties: { + id: { type: 'string', description: 'Asset ID' }, + name: { type: 'string', description: 'Asset/company name' }, + website: { type: 'string', description: 'Asset website URL' }, + alternateDomains: { type: 'json', description: 'Alternate domain names' }, + isOwned: { + type: 'boolean', + description: 'Whether this asset is owned by the organization', + }, + createdAt: { type: 'string', description: 'When the asset was created' }, + logoUrl: { type: 'string', description: 'URL of the asset logo' }, + categoryId: { type: 'string', description: 'Category ID the asset belongs to' }, + categoryName: { type: 'string', description: 'Category name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/list_categories.ts b/apps/sim/tools/profound/list_categories.ts new file mode 100644 index 00000000000..29f5ab5eb17 --- /dev/null +++ b/apps/sim/tools/profound/list_categories.ts @@ -0,0 +1,57 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundListCategoriesParams, ProfoundListCategoriesResponse } from './types' + +export const profoundListCategoriesTool: ToolConfig< + ProfoundListCategoriesParams, + ProfoundListCategoriesResponse +> = { + id: 'profound_list_categories', + name: 'Profound List Categories', + description: 'List all organization categories in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/org/categories', + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list categories') + } + return { + success: true, + output: { + categories: (data ?? []).map((item: { id: string; name: string }) => ({ + id: item.id ?? null, + name: item.name ?? null, + })), + }, + } + }, + + outputs: { + categories: { + type: 'json', + description: 'List of organization categories', + properties: { + id: { type: 'string', description: 'Category ID' }, + name: { type: 'string', description: 'Category name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/list_domains.ts b/apps/sim/tools/profound/list_domains.ts new file mode 100644 index 00000000000..b723bffbcb7 --- /dev/null +++ b/apps/sim/tools/profound/list_domains.ts @@ -0,0 +1,59 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundListDomainsParams, ProfoundListDomainsResponse } from './types' + +export const profoundListDomainsTool: ToolConfig< + ProfoundListDomainsParams, + ProfoundListDomainsResponse +> = { + id: 'profound_list_domains', + name: 'Profound List Domains', + description: 'List all organization domains in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/org/domains', + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list domains') + } + return { + success: true, + output: { + domains: (data ?? []).map((item: { id: string; name: string; created_at: string }) => ({ + id: item.id ?? null, + name: item.name ?? null, + createdAt: item.created_at ?? null, + })), + }, + } + }, + + outputs: { + domains: { + type: 'json', + description: 'List of organization domains', + properties: { + id: { type: 'string', description: 'Domain ID (UUID)' }, + name: { type: 'string', description: 'Domain name' }, + createdAt: { type: 'string', description: 'When the domain was added' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/list_models.ts b/apps/sim/tools/profound/list_models.ts new file mode 100644 index 00000000000..a1cc9da54a4 --- /dev/null +++ b/apps/sim/tools/profound/list_models.ts @@ -0,0 +1,57 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundListModelsParams, ProfoundListModelsResponse } from './types' + +export const profoundListModelsTool: ToolConfig< + ProfoundListModelsParams, + ProfoundListModelsResponse +> = { + id: 'profound_list_models', + name: 'Profound List Models', + description: 'List all AI models/platforms tracked in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/org/models', + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list models') + } + return { + success: true, + output: { + models: (data ?? []).map((item: { id: string; name: string }) => ({ + id: item.id ?? null, + name: item.name ?? null, + })), + }, + } + }, + + outputs: { + models: { + type: 'json', + description: 'List of AI models/platforms', + properties: { + id: { type: 'string', description: 'Model ID (UUID)' }, + name: { type: 'string', description: 'Model/platform name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/list_optimizations.ts b/apps/sim/tools/profound/list_optimizations.ts new file mode 100644 index 00000000000..ca072e9bf71 --- /dev/null +++ b/apps/sim/tools/profound/list_optimizations.ts @@ -0,0 +1,104 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundListOptimizationsParams, ProfoundListOptimizationsResponse } from './types' + +export const profoundListOptimizationsTool: ToolConfig< + ProfoundListOptimizationsParams, + ProfoundListOptimizationsResponse +> = { + id: 'profound_list_optimizations', + name: 'Profound List Optimizations', + description: 'List content optimization entries for an asset in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + assetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Asset ID (UUID)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Offset for pagination (default 0)', + }, + }, + + request: { + url: (params) => { + const url = new URL( + `https://api.tryprofound.com/v1/content/${encodeURIComponent(params.assetId)}/optimization` + ) + if (params.limit != null) url.searchParams.set('limit', String(params.limit)) + if (params.offset != null) url.searchParams.set('offset', String(params.offset)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list optimizations') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + optimizations: (data.data ?? []).map( + (item: { + id: string + title: string + created_at: string + extracted_input: string | null + type: string + status: string + }) => ({ + id: item.id ?? null, + title: item.title ?? null, + createdAt: item.created_at ?? null, + extractedInput: item.extracted_input ?? null, + type: item.type ?? null, + status: item.status ?? null, + }) + ), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of optimization entries', + }, + optimizations: { + type: 'json', + description: 'List of content optimization entries', + properties: { + id: { type: 'string', description: 'Optimization ID (UUID)' }, + title: { type: 'string', description: 'Content title' }, + createdAt: { type: 'string', description: 'When the optimization was created' }, + extractedInput: { type: 'string', description: 'Extracted input text' }, + type: { type: 'string', description: 'Content type: file, text, or url' }, + status: { type: 'string', description: 'Optimization status' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/list_personas.ts b/apps/sim/tools/profound/list_personas.ts new file mode 100644 index 00000000000..31aac7b8dc6 --- /dev/null +++ b/apps/sim/tools/profound/list_personas.ts @@ -0,0 +1,96 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundListPersonasParams, ProfoundListPersonasResponse } from './types' + +export const profoundListPersonasTool: ToolConfig< + ProfoundListPersonasParams, + ProfoundListPersonasResponse +> = { + id: 'profound_list_personas', + name: 'Profound List Personas', + description: 'List all organization personas across all categories in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/org/personas', + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list personas') + } + return { + success: true, + output: { + personas: (data.data ?? []).map( + (item: { + id: string + name: string + category: { id: string; name: string } + persona: { + behavior: { painPoints: string | null; motivations: string | null } + employment: { + industry: string[] + jobTitle: string[] + companySize: string[] + roleSeniority: string[] + } + demographics: { ageRange: string[] } + } + }) => ({ + id: item.id ?? null, + name: item.name ?? null, + categoryId: item.category?.id ?? null, + categoryName: item.category?.name ?? null, + persona: { + behavior: { + painPoints: item.persona?.behavior?.painPoints ?? null, + motivations: item.persona?.behavior?.motivations ?? null, + }, + employment: { + industry: item.persona?.employment?.industry ?? [], + jobTitle: item.persona?.employment?.jobTitle ?? [], + companySize: item.persona?.employment?.companySize ?? [], + roleSeniority: item.persona?.employment?.roleSeniority ?? [], + }, + demographics: { + ageRange: item.persona?.demographics?.ageRange ?? [], + }, + }, + }) + ), + }, + } + }, + + outputs: { + personas: { + type: 'json', + description: 'List of organization personas with profile details', + properties: { + id: { type: 'string', description: 'Persona ID' }, + name: { type: 'string', description: 'Persona name' }, + categoryId: { type: 'string', description: 'Category ID' }, + categoryName: { type: 'string', description: 'Category name' }, + persona: { + type: 'json', + description: 'Persona profile with behavior, employment, and demographics', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/list_regions.ts b/apps/sim/tools/profound/list_regions.ts new file mode 100644 index 00000000000..f3cc60b5535 --- /dev/null +++ b/apps/sim/tools/profound/list_regions.ts @@ -0,0 +1,57 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundListRegionsParams, ProfoundListRegionsResponse } from './types' + +export const profoundListRegionsTool: ToolConfig< + ProfoundListRegionsParams, + ProfoundListRegionsResponse +> = { + id: 'profound_list_regions', + name: 'Profound List Regions', + description: 'List all organization regions in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/org/regions', + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to list regions') + } + return { + success: true, + output: { + regions: (data ?? []).map((item: { id: string; name: string }) => ({ + id: item.id ?? null, + name: item.name ?? null, + })), + }, + } + }, + + outputs: { + regions: { + type: 'json', + description: 'List of organization regions', + properties: { + id: { type: 'string', description: 'Region ID (UUID)' }, + name: { type: 'string', description: 'Region name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/optimization_analysis.ts b/apps/sim/tools/profound/optimization_analysis.ts new file mode 100644 index 00000000000..b2d64606384 --- /dev/null +++ b/apps/sim/tools/profound/optimization_analysis.ts @@ -0,0 +1,161 @@ +import type { ToolConfig } from '@/tools/types' +import type { + ProfoundOptimizationAnalysisParams, + ProfoundOptimizationAnalysisResponse, +} from './types' + +export const profoundOptimizationAnalysisTool: ToolConfig< + ProfoundOptimizationAnalysisParams, + ProfoundOptimizationAnalysisResponse +> = { + id: 'profound_optimization_analysis', + name: 'Profound Optimization Analysis', + description: 'Get detailed content optimization analysis for a specific content item in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + assetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Asset ID (UUID)', + }, + contentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Content/optimization ID (UUID)', + }, + }, + + request: { + url: (params) => + `https://api.tryprofound.com/v1/content/${encodeURIComponent(params.assetId)}/optimization/${encodeURIComponent(params.contentId)}`, + method: 'GET', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to get optimization analysis') + } + const analysis = data.data + return { + success: true, + output: { + content: { + format: analysis?.content?.format ?? null, + value: analysis?.content?.value ?? null, + }, + aeoContentScore: analysis?.aeo_content_score + ? { + value: analysis.aeo_content_score.value ?? 0, + targetZone: { + low: analysis.aeo_content_score.target_zone?.low ?? 0, + high: analysis.aeo_content_score.target_zone?.high ?? 0, + }, + } + : null, + analysis: { + breakdown: (analysis?.analysis?.breakdown ?? []).map( + (b: { title: string; weight: number; score: number }) => ({ + title: b.title ?? null, + weight: b.weight ?? 0, + score: b.score ?? 0, + }) + ), + }, + recommendations: (analysis?.recommendations ?? []).map( + (r: { + title: string + status: string + impact: { section: string; score: number } | null + suggestion: { text: string; rationale: string } + }) => ({ + title: r.title ?? null, + status: r.status ?? null, + impact: r.impact + ? { + section: r.impact.section ?? null, + score: r.impact.score ?? 0, + } + : null, + suggestion: { + text: r.suggestion?.text ?? null, + rationale: r.suggestion?.rationale ?? null, + }, + }) + ), + }, + } + }, + + outputs: { + content: { + type: 'json', + description: 'The analyzed content', + properties: { + format: { type: 'string', description: 'Content format: markdown or html' }, + value: { type: 'string', description: 'Content text' }, + }, + }, + aeoContentScore: { + type: 'json', + description: 'AEO content score with target zone', + optional: true, + properties: { + value: { type: 'number', description: 'AEO score value' }, + targetZone: { + type: 'json', + description: 'Target zone range', + properties: { + low: { type: 'number', description: 'Low end of target range' }, + high: { type: 'number', description: 'High end of target range' }, + }, + }, + }, + }, + analysis: { + type: 'json', + description: 'Analysis breakdown by category', + properties: { + breakdown: { + type: 'json', + description: 'Array of scoring breakdowns', + properties: { + title: { type: 'string', description: 'Category title' }, + weight: { type: 'number', description: 'Category weight' }, + score: { type: 'number', description: 'Category score' }, + }, + }, + }, + }, + recommendations: { + type: 'json', + description: 'Content optimization recommendations', + properties: { + title: { type: 'string', description: 'Recommendation title' }, + status: { type: 'string', description: 'Status: done or pending' }, + impact: { type: 'json', description: 'Impact details with section and score' }, + suggestion: { + type: 'json', + description: 'Suggestion text and rationale', + properties: { + text: { type: 'string', description: 'Suggestion text' }, + rationale: { type: 'string', description: 'Why this recommendation matters' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/prompt_answers.ts b/apps/sim/tools/profound/prompt_answers.ts new file mode 100644 index 00000000000..1626c6aca67 --- /dev/null +++ b/apps/sim/tools/profound/prompt_answers.ts @@ -0,0 +1,141 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundPromptAnswersParams, ProfoundPromptAnswersResponse } from './types' + +export const profoundPromptAnswersTool: ToolConfig< + ProfoundPromptAnswersParams, + ProfoundPromptAnswersResponse +> = { + id: 'profound_prompt_answers', + name: 'Profound Prompt Answers', + description: 'Get raw prompt answers data for a category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"prompt_type","operator":"is","value":"visibility"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/prompts/answers', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + category_id: params.categoryId, + start_date: params.startDate, + end_date: params.endDate, + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to get prompt answers') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map( + (row: { + prompt: string | null + prompt_type: string | null + response: string | null + mentions: string[] | null + citations: string[] | null + topic: string | null + region: string | null + model: string | null + asset: string | null + created_at: string | null + }) => ({ + prompt: row.prompt ?? null, + promptType: row.prompt_type ?? null, + response: row.response ?? null, + mentions: row.mentions ?? [], + citations: row.citations ?? [], + topic: row.topic ?? null, + region: row.region ?? null, + model: row.model ?? null, + asset: row.asset ?? null, + createdAt: row.created_at ?? null, + }) + ), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of answer rows', + }, + data: { + type: 'json', + description: 'Raw prompt answer data', + properties: { + prompt: { type: 'string', description: 'The prompt text' }, + promptType: { type: 'string', description: 'Prompt type (visibility or sentiment)' }, + response: { type: 'string', description: 'AI model response text' }, + mentions: { type: 'json', description: 'Companies/assets mentioned in the response' }, + citations: { type: 'json', description: 'URLs cited in the response' }, + topic: { type: 'string', description: 'Topic name' }, + region: { type: 'string', description: 'Region name' }, + model: { type: 'string', description: 'AI model/platform name' }, + asset: { type: 'string', description: 'Asset name' }, + createdAt: { type: 'string', description: 'Timestamp when the answer was collected' }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/prompt_volume.ts b/apps/sim/tools/profound/prompt_volume.ts new file mode 100644 index 00000000000..904b6284d82 --- /dev/null +++ b/apps/sim/tools/profound/prompt_volume.ts @@ -0,0 +1,138 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundPromptVolumeParams, ProfoundPromptVolumeResponse } from './types' + +export const profoundPromptVolumeTool: ToolConfig< + ProfoundPromptVolumeParams, + ProfoundPromptVolumeResponse +> = { + id: 'profound_prompt_volume', + name: 'Profound Prompt Volume', + description: + 'Query prompt volume data to understand search demand across AI platforms in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601)', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated metrics: volume, change', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated dimensions: keyword, date, platform, country_code, matching_type, frequency', + }, + dateInterval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date interval: hour, day, week, month, year', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"keyword","operator":"contains","value":"best"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/prompt-volumes/volume', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + start_date: params.startDate, + end_date: params.endDate, + metrics: params.metrics.split(',').map((m) => m.trim()), + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.dateInterval) { + body.date_interval = params.dateInterval + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to query prompt volume') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of rows in the report', + }, + data: { + type: 'json', + description: 'Volume data rows with metrics and dimension values', + properties: { + metrics: { + type: 'json', + description: 'Array of metric values matching requested metrics order', + }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/query_fanouts.ts b/apps/sim/tools/profound/query_fanouts.ts new file mode 100644 index 00000000000..cbb46b1c18a --- /dev/null +++ b/apps/sim/tools/profound/query_fanouts.ts @@ -0,0 +1,143 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundQueryFanoutsParams, ProfoundQueryFanoutsResponse } from './types' + +export const profoundQueryFanoutsTool: ToolConfig< + ProfoundQueryFanoutsParams, + ProfoundQueryFanoutsResponse +> = { + id: 'profound_query_fanouts', + name: 'Profound Query Fanouts', + description: + 'Query fanout report showing how AI models expand prompts into sub-queries in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601)', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated metrics: fanouts_per_execution, total_fanouts, share', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated dimensions: prompt, query, model, region, date', + }, + dateInterval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date interval: hour, day, week, month, year', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of filter objects', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/reports/query-fanouts', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + category_id: params.categoryId, + start_date: params.startDate, + end_date: params.endDate, + metrics: params.metrics.split(',').map((m) => m.trim()), + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.dateInterval) { + body.date_interval = params.dateInterval + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to query fanouts report') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of rows in the report', + }, + data: { + type: 'json', + description: 'Report data rows with metrics and dimension values', + properties: { + metrics: { + type: 'json', + description: 'Array of metric values matching requested metrics order', + }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/raw_logs.ts b/apps/sim/tools/profound/raw_logs.ts new file mode 100644 index 00000000000..93f65907887 --- /dev/null +++ b/apps/sim/tools/profound/raw_logs.ts @@ -0,0 +1,137 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundRawLogsParams, ProfoundRawLogsResponse } from './types' + +export const profoundRawLogsTool: ToolConfig = { + id: 'profound_raw_logs', + name: 'Profound Raw Logs', + description: 'Get raw traffic logs with filters for a domain in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Domain to query logs for (e.g. example.com)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601). Defaults to now', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"path","operator":"contains","value":"/blog"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/logs/raw', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + domain: params.domain, + start_date: params.startDate, + metrics: ['count'], + } + if (params.endDate) { + body.end_date = params.endDate + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to get raw logs') + } + if (Array.isArray(data)) { + return { + success: true, + output: { + totalRows: data.length, + data: data.map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of log entries', + }, + data: { + type: 'json', + description: 'Log data rows with metrics and dimension values', + properties: { + metrics: { type: 'json', description: 'Array of metric values (count)' }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/referrals_report.ts b/apps/sim/tools/profound/referrals_report.ts new file mode 100644 index 00000000000..b3036668620 --- /dev/null +++ b/apps/sim/tools/profound/referrals_report.ts @@ -0,0 +1,146 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundReferralsReportParams, ProfoundReferralsReportResponse } from './types' + +export const profoundReferralsReportTool: ToolConfig< + ProfoundReferralsReportParams, + ProfoundReferralsReportResponse +> = { + id: 'profound_referrals_report', + name: 'Profound Referrals Report', + description: + 'Query human referral traffic report with hourly granularity for a domain in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Domain to query referral traffic for (e.g. example.com)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601). Defaults to now', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated metrics: visits, last_visit', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated dimensions: date, hour, path, referral_source, referral_type', + }, + dateInterval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date interval: hour, day, week, month, year', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"referral_source","operator":"is","value":"openai"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v2/reports/referrals', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + domain: params.domain, + start_date: params.startDate, + metrics: params.metrics.split(',').map((m) => m.trim()), + } + if (params.endDate) { + body.end_date = params.endDate + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.dateInterval) { + body.date_interval = params.dateInterval + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to query referrals report') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of rows in the report', + }, + data: { + type: 'json', + description: 'Report data rows with metrics and dimension values', + properties: { + metrics: { + type: 'json', + description: 'Array of metric values matching requested metrics order', + }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/sentiment_report.ts b/apps/sim/tools/profound/sentiment_report.ts new file mode 100644 index 00000000000..54da5be8d31 --- /dev/null +++ b/apps/sim/tools/profound/sentiment_report.ts @@ -0,0 +1,144 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundSentimentReportParams, ProfoundSentimentReportResponse } from './types' + +export const profoundSentimentReportTool: ToolConfig< + ProfoundSentimentReportParams, + ProfoundSentimentReportResponse +> = { + id: 'profound_sentiment_report', + name: 'Profound Sentiment Report', + description: 'Query sentiment report for a category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601)', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated metrics: positive, negative, occurrences', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated dimensions: theme, date, region, topic, model, asset_name, tag, prompt, sentiment_type, persona', + }, + dateInterval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date interval: hour, day, week, month, year', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"asset_name","operator":"is","value":"Company"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/reports/sentiment', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + category_id: params.categoryId, + start_date: params.startDate, + end_date: params.endDate, + metrics: params.metrics.split(',').map((m) => m.trim()), + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.dateInterval) { + body.date_interval = params.dateInterval + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to query sentiment report') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of rows in the report', + }, + data: { + type: 'json', + description: 'Report data rows with metrics and dimension values', + properties: { + metrics: { + type: 'json', + description: 'Array of metric values matching requested metrics order', + }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/profound/types.ts b/apps/sim/tools/profound/types.ts new file mode 100644 index 00000000000..3ad94fec2fb --- /dev/null +++ b/apps/sim/tools/profound/types.ts @@ -0,0 +1,422 @@ +import type { ToolResponse } from '@/tools/types' + +/** Shared report response shape (visibility, sentiment, citations, bots, referrals, query fanouts, prompt volume) */ +export interface ProfoundReportResponse extends ToolResponse { + output: { + totalRows: number + data: Array<{ + metrics: number[] + dimensions: string[] + }> + } +} + +/** Shared report query params for category-based reports */ +export interface ProfoundCategoryReportParams { + apiKey: string + categoryId: string + startDate: string + endDate: string + metrics: string + dimensions?: string + dateInterval?: string + filters?: string + limit?: number +} + +/** Shared report query params for domain-based reports */ +export interface ProfoundDomainReportParams { + apiKey: string + domain: string + startDate: string + endDate?: string + metrics: string + dimensions?: string + dateInterval?: string + filters?: string + limit?: number +} + +// --- Organization endpoints --- + +export interface ProfoundListCategoriesParams { + apiKey: string +} + +export interface ProfoundListCategoriesResponse extends ToolResponse { + output: { + categories: Array<{ + id: string + name: string + }> + } +} + +export interface ProfoundListRegionsParams { + apiKey: string +} + +export interface ProfoundListRegionsResponse extends ToolResponse { + output: { + regions: Array<{ + id: string + name: string + }> + } +} + +export interface ProfoundListModelsParams { + apiKey: string +} + +export interface ProfoundListModelsResponse extends ToolResponse { + output: { + models: Array<{ + id: string + name: string + }> + } +} + +export interface ProfoundListDomainsParams { + apiKey: string +} + +export interface ProfoundListDomainsResponse extends ToolResponse { + output: { + domains: Array<{ + id: string + name: string + createdAt: string + }> + } +} + +export interface ProfoundListAssetsParams { + apiKey: string +} + +export interface ProfoundListAssetsResponse extends ToolResponse { + output: { + assets: Array<{ + id: string + name: string + website: string + alternateDomains: string[] | null + isOwned: boolean + createdAt: string + logoUrl: string + categoryId: string + categoryName: string + }> + } +} + +export interface ProfoundListPersonasParams { + apiKey: string +} + +export interface ProfoundListPersonasResponse extends ToolResponse { + output: { + personas: Array<{ + id: string + name: string + categoryId: string + categoryName: string + persona: { + behavior: { painPoints: string | null; motivations: string | null } + employment: { + industry: string[] + jobTitle: string[] + companySize: string[] + roleSeniority: string[] + } + demographics: { ageRange: string[] } + } + }> + } +} + +// --- Category-specific endpoints --- + +export interface ProfoundCategoryTopicsParams { + apiKey: string + categoryId: string +} + +export interface ProfoundCategoryTopicsResponse extends ToolResponse { + output: { + topics: Array<{ + id: string + name: string + }> + } +} + +export interface ProfoundCategoryTagsParams { + apiKey: string + categoryId: string +} + +export interface ProfoundCategoryTagsResponse extends ToolResponse { + output: { + tags: Array<{ + id: string + name: string + }> + } +} + +export interface ProfoundCategoryPromptsParams { + apiKey: string + categoryId: string + limit?: number + cursor?: string + orderDir?: string + promptType?: string + topicId?: string + tagId?: string + regionId?: string + platformId?: string +} + +export interface ProfoundCategoryPromptsResponse extends ToolResponse { + output: { + totalRows: number + nextCursor: string | null + prompts: Array<{ + id: string + prompt: string + promptType: string + topicId: string + topicName: string + tags: Array<{ id: string; name: string }> + regions: Array<{ id: string; name: string }> + platforms: Array<{ id: string; name: string }> + createdAt: string + }> + } +} + +export interface ProfoundCategoryAssetsParams { + apiKey: string + categoryId: string +} + +export interface ProfoundCategoryAssetsResponse extends ToolResponse { + output: { + assets: Array<{ + id: string + name: string + website: string + alternateDomains: string[] | null + isOwned: boolean + createdAt: string + logoUrl: string + }> + } +} + +export interface ProfoundCategoryPersonasParams { + apiKey: string + categoryId: string +} + +export interface ProfoundCategoryPersonasResponse extends ToolResponse { + output: { + personas: Array<{ + id: string + name: string + persona: { + behavior: { painPoints: string | null; motivations: string | null } + employment: { + industry: string[] + jobTitle: string[] + companySize: string[] + roleSeniority: string[] + } + demographics: { ageRange: string[] } + } + }> + } +} + +// --- Reports --- + +export type ProfoundVisibilityReportParams = ProfoundCategoryReportParams +export type ProfoundVisibilityReportResponse = ProfoundReportResponse + +export type ProfoundSentimentReportParams = ProfoundCategoryReportParams +export type ProfoundSentimentReportResponse = ProfoundReportResponse + +export type ProfoundCitationsReportParams = ProfoundCategoryReportParams +export type ProfoundCitationsReportResponse = ProfoundReportResponse + +export type ProfoundQueryFanoutsParams = ProfoundCategoryReportParams +export type ProfoundQueryFanoutsResponse = ProfoundReportResponse + +export type ProfoundBotsReportParams = ProfoundDomainReportParams +export type ProfoundBotsReportResponse = ProfoundReportResponse + +export type ProfoundReferralsReportParams = ProfoundDomainReportParams +export type ProfoundReferralsReportResponse = ProfoundReportResponse + +// --- Prompts --- + +export interface ProfoundPromptAnswersParams { + apiKey: string + categoryId: string + startDate: string + endDate: string + filters?: string + limit?: number +} + +export interface ProfoundPromptAnswersResponse extends ToolResponse { + output: { + totalRows: number + data: Array<{ + prompt: string | null + promptType: string | null + response: string | null + mentions: string[] | null + citations: string[] | null + topic: string | null + region: string | null + model: string | null + asset: string | null + createdAt: string | null + }> + } +} + +// --- Agent Analytics --- + +export interface ProfoundRawLogsParams { + apiKey: string + domain: string + startDate: string + endDate?: string + dimensions?: string + filters?: string + limit?: number +} + +export interface ProfoundRawLogsResponse extends ToolResponse { + output: { + totalRows: number + data: Array<{ + metrics: number[] + dimensions: string[] + }> + } +} + +export interface ProfoundBotLogsParams { + apiKey: string + domain: string + startDate: string + endDate?: string + dimensions?: string + filters?: string + limit?: number +} + +export interface ProfoundBotLogsResponse extends ToolResponse { + output: { + totalRows: number + data: Array<{ + metrics: number[] + dimensions: string[] + }> + } +} + +// --- Content --- + +export interface ProfoundListOptimizationsParams { + apiKey: string + assetId: string + limit?: number + offset?: number +} + +export interface ProfoundListOptimizationsResponse extends ToolResponse { + output: { + totalRows: number + optimizations: Array<{ + id: string + title: string + createdAt: string + extractedInput: string | null + type: string + status: string + }> + } +} + +export interface ProfoundOptimizationAnalysisParams { + apiKey: string + assetId: string + contentId: string +} + +export interface ProfoundOptimizationAnalysisResponse extends ToolResponse { + output: { + content: { + format: string + value: string + } + aeoContentScore: { + value: number + targetZone: { low: number; high: number } + } | null + analysis: { + breakdown: Array<{ + title: string + weight: number + score: number + }> + } + recommendations: Array<{ + title: string + status: string + impact: { section: string; score: number } | null + suggestion: { text: string; rationale: string } + }> + } +} + +// --- Prompt Volumes --- + +export interface ProfoundPromptVolumeParams { + apiKey: string + startDate: string + endDate: string + metrics: string + dimensions?: string + dateInterval?: string + filters?: string + limit?: number +} + +export interface ProfoundPromptVolumeResponse extends ToolResponse { + output: { + totalRows: number + data: Array<{ + metrics: number[] + dimensions: string[] + }> + } +} + +export interface ProfoundCitationPromptsParams { + apiKey: string + inputDomain: string +} + +export interface ProfoundCitationPromptsResponse extends ToolResponse { + output: { + data: unknown + } +} diff --git a/apps/sim/tools/profound/visibility_report.ts b/apps/sim/tools/profound/visibility_report.ts new file mode 100644 index 00000000000..b7fdd91dd79 --- /dev/null +++ b/apps/sim/tools/profound/visibility_report.ts @@ -0,0 +1,145 @@ +import type { ToolConfig } from '@/tools/types' +import type { ProfoundVisibilityReportParams, ProfoundVisibilityReportResponse } from './types' + +export const profoundVisibilityReportTool: ToolConfig< + ProfoundVisibilityReportParams, + ProfoundVisibilityReportResponse +> = { + id: 'profound_visibility_report', + name: 'Profound Visibility Report', + description: 'Query AI visibility report for a category in Profound', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Profound API Key', + }, + categoryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Category ID (UUID)', + }, + startDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date (YYYY-MM-DD or ISO 8601)', + }, + endDate: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date (YYYY-MM-DD or ISO 8601)', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated metrics: share_of_voice, mentions_count, visibility_score, executions, average_position', + }, + dimensions: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated dimensions: date, region, topic, model, asset_name, prompt, tag, persona', + }, + dateInterval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Date interval: hour, day, week, month, year', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects, e.g. [{"field":"asset_name","operator":"is","value":"Company"}]', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (default 10000, max 50000)', + }, + }, + + request: { + url: 'https://api.tryprofound.com/v1/reports/visibility', + method: 'POST', + headers: (params) => ({ + 'X-API-Key': params.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + category_id: params.categoryId, + start_date: params.startDate, + end_date: params.endDate, + metrics: params.metrics.split(',').map((m) => m.trim()), + } + if (params.dimensions) { + body.dimensions = params.dimensions.split(',').map((d) => d.trim()) + } + if (params.dateInterval) { + body.date_interval = params.dateInterval + } + if (params.filters) { + try { + body.filters = JSON.parse(params.filters) + } catch { + throw new Error('Invalid JSON in filters parameter') + } + } + if (params.limit != null) { + body.pagination = { limit: params.limit } + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.detail?.[0]?.msg || 'Failed to query visibility report') + } + return { + success: true, + output: { + totalRows: data.info?.total_rows ?? 0, + data: (data.data ?? []).map((row: { metrics: number[]; dimensions: string[] }) => ({ + metrics: row.metrics ?? [], + dimensions: row.dimensions ?? [], + })), + }, + } + }, + + outputs: { + totalRows: { + type: 'number', + description: 'Total number of rows in the report', + }, + data: { + type: 'json', + description: 'Report data rows with metrics and dimension values', + properties: { + metrics: { + type: 'json', + description: 'Array of metric values matching requested metrics order', + }, + dimensions: { + type: 'json', + description: 'Array of dimension values matching requested dimensions order', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2ba6281a52d..5c269a7c168 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1777,6 +1777,32 @@ import { posthogUpdatePropertyDefinitionTool, posthogUpdateSurveyTool, } from '@/tools/posthog' +import { + profoundBotLogsTool, + profoundBotsReportTool, + profoundCategoryAssetsTool, + profoundCategoryPersonasTool, + profoundCategoryPromptsTool, + profoundCategoryTagsTool, + profoundCategoryTopicsTool, + profoundCitationPromptsTool, + profoundCitationsReportTool, + profoundListAssetsTool, + profoundListCategoriesTool, + profoundListDomainsTool, + profoundListModelsTool, + profoundListOptimizationsTool, + profoundListPersonasTool, + profoundListRegionsTool, + profoundOptimizationAnalysisTool, + profoundPromptAnswersTool, + profoundPromptVolumeTool, + profoundQueryFanoutsTool, + profoundRawLogsTool, + profoundReferralsReportTool, + profoundSentimentReportTool, + profoundVisibilityReportTool, +} from '@/tools/profound' import { pulseParserTool, pulseParserV2Tool } from '@/tools/pulse' import { qdrantFetchTool, qdrantSearchTool, qdrantUpsertTool } from '@/tools/qdrant' import { quiverImageToSvgTool, quiverListModelsTool, quiverTextToSvgTool } from '@/tools/quiver' @@ -3631,6 +3657,30 @@ export const tools: Record = { google_slides_insert_text: googleSlidesInsertTextTool, perplexity_chat: perplexityChatTool, perplexity_search: perplexitySearchTool, + profound_bot_logs: profoundBotLogsTool, + profound_bots_report: profoundBotsReportTool, + profound_category_assets: profoundCategoryAssetsTool, + profound_category_personas: profoundCategoryPersonasTool, + profound_category_prompts: profoundCategoryPromptsTool, + profound_category_tags: profoundCategoryTagsTool, + profound_category_topics: profoundCategoryTopicsTool, + profound_citation_prompts: profoundCitationPromptsTool, + profound_citations_report: profoundCitationsReportTool, + profound_list_assets: profoundListAssetsTool, + profound_list_categories: profoundListCategoriesTool, + profound_list_domains: profoundListDomainsTool, + profound_list_models: profoundListModelsTool, + profound_list_optimizations: profoundListOptimizationsTool, + profound_list_personas: profoundListPersonasTool, + profound_list_regions: profoundListRegionsTool, + profound_optimization_analysis: profoundOptimizationAnalysisTool, + profound_prompt_answers: profoundPromptAnswersTool, + profound_prompt_volume: profoundPromptVolumeTool, + profound_query_fanouts: profoundQueryFanoutsTool, + profound_raw_logs: profoundRawLogsTool, + profound_referrals_report: profoundReferralsReportTool, + profound_sentiment_report: profoundSentimentReportTool, + profound_visibility_report: profoundVisibilityReportTool, pulse_parser: pulseParserTool, pulse_parser_v2: pulseParserV2Tool, quiver_image_to_svg: quiverImageToSvgTool, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 9f6b4387fa8..13a1c509dfa 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -205,12 +205,27 @@ async function generateIconMapping(): Promise> { * Write the icon mapping to the docs app * This file is imported by BlockInfoCard to resolve icons automatically */ +/** + * Sort strings to match Biome's organizeImports order: + * case-insensitive character-by-character, uppercase before lowercase as tiebreaker. + */ +function biomeSortCompare(a: string, b: string): number { + const minLen = Math.min(a.length, b.length) + for (let i = 0; i < minLen; i++) { + const al = a[i].toLowerCase() + const bl = b[i].toLowerCase() + if (al !== bl) return al < bl ? -1 : 1 + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1 + } + return a.length - b.length +} + function writeIconMapping(iconMapping: Record): void { try { const iconMappingPath = path.join(rootDir, 'apps/docs/components/ui/icon-mapping.ts') - // Get unique icon names - const iconNames = [...new Set(Object.values(iconMapping))].sort() + // Get unique icon names, sorted to match Biome's organizeImports + const iconNames = [...new Set(Object.values(iconMapping))].sort(biomeSortCompare) // Generate imports const imports = iconNames.map((icon) => ` ${icon},`).join('\n') @@ -508,7 +523,7 @@ function writeIntegrationsIconMapping(iconMapping: Record): void } const iconMappingPath = path.join(LANDING_INTEGRATIONS_DATA_PATH, 'icon-mapping.ts') - const iconNames = [...new Set(Object.values(iconMapping))].sort() + const iconNames = [...new Set(Object.values(iconMapping))].sort(biomeSortCompare) const imports = iconNames.map((icon) => ` ${icon},`).join('\n') const mappingEntries = Object.entries(iconMapping) .sort(([a], [b]) => a.localeCompare(b)) @@ -664,7 +679,16 @@ async function writeIntegrationsJson(iconMapping: Record): Promi integrations.sort((a, b) => a.name.localeCompare(b.name)) const jsonPath = path.join(LANDING_INTEGRATIONS_DATA_PATH, 'integrations.json') - fs.writeFileSync(jsonPath, JSON.stringify(integrations, null, 2)) + // JSON.stringify always expands arrays across multiple lines. Biome's formatter + // collapses short arrays of primitives onto single lines. Post-process to match. + const json = JSON.stringify(integrations, null, 2).replace( + /\[\n(\s+"[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s+\]/g, + (_match, inner) => { + const items = (inner as string).split(',\n').map((s: string) => s.trim()) + return `[${items.join(', ')}]` + } + ) + fs.writeFileSync(jsonPath, `${json}\n`) console.log(`✓ Integration data written: ${integrations.length} integrations → ${jsonPath}`) } catch (error) { console.error('Error writing integrations JSON:', error) @@ -2813,7 +2837,7 @@ function updateMetaJson() { pages: items, } - fs.writeFileSync(metaJsonPath, JSON.stringify(metaJson, null, 2)) + fs.writeFileSync(metaJsonPath, `${JSON.stringify(metaJson, null, 2)}\n`) console.log(`Updated meta.json with ${items.length} entries`) } From c7643198dc90230f2d926cb327b886fc836d4481 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 30 Mar 2026 16:47:54 -0700 Subject: [PATCH 08/15] fix(mothership): hang condition (#3852) --- apps/sim/app/api/mothership/chat/stop/route.ts | 3 +++ apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/sim/app/api/mothership/chat/stop/route.ts b/apps/sim/app/api/mothership/chat/stop/route.ts index 763ff9b2cfc..8eb5185ff1e 100644 --- a/apps/sim/app/api/mothership/chat/stop/route.ts +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -5,6 +5,7 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { releasePendingChatStream } from '@/lib/copilot/chat-streaming' import { taskPubSub } from '@/lib/copilot/task-events' const logger = createLogger('MothershipChatStopAPI') @@ -58,6 +59,8 @@ export async function POST(req: NextRequest) { const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json()) + await releasePendingChatStream(chatId, streamId) + const setClause: Record = { conversationId: null, updatedAt: new Date(), 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 3b3511ab0f3..7b5c1cda635 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1737,6 +1737,8 @@ export function useChat( } if (options?.error) { + pendingRecoveryMessageRef.current = null + setPendingRecoveryMessage(null) setMessageQueue([]) return } From 27460f847c11dd66610d8d89916f7cd5ed1684cb Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 16:54:45 -0700 Subject: [PATCH 09/15] fix(atlassian): harden cloud ID resolution for Confluence and Jira (#3853) --- apps/sim/tools/confluence/utils.ts | 30 ++++++++++++++++++++++-------- apps/sim/tools/jira/utils.ts | 30 ++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/sim/tools/confluence/utils.ts b/apps/sim/tools/confluence/utils.ts index 303464bdbf2..99c2fb17d70 100644 --- a/apps/sim/tools/confluence/utils.ts +++ b/apps/sim/tools/confluence/utils.ts @@ -1,6 +1,13 @@ import type { RetryOptions } from '@/lib/knowledge/documents/utils' import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +function normalizeDomain(domain: string): string { + return `https://${domain + .trim() + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '')}`.toLowerCase() +} + export async function getConfluenceCloudId( domain: string, accessToken: string, @@ -20,20 +27,27 @@ export async function getConfluenceCloudId( const resources = await response.json() - if (Array.isArray(resources) && resources.length > 0) { - const normalizedInput = `https://${domain}`.toLowerCase() - const matchedResource = resources.find((r) => r.url.toLowerCase() === normalizedInput) + if (!Array.isArray(resources) || resources.length === 0) { + throw new Error('No Confluence resources found') + } + + const normalized = normalizeDomain(domain) + const match = resources.find( + (r: { url: string }) => r.url.toLowerCase().replace(/\/+$/, '') === normalized + ) - if (matchedResource) { - return matchedResource.id - } + if (match) { + return match.id } - if (Array.isArray(resources) && resources.length > 0) { + if (resources.length === 1) { return resources[0].id } - throw new Error('No Confluence resources found') + throw new Error( + `Could not match Confluence domain "${domain}" to any accessible resource. ` + + `Available sites: ${resources.map((r: { url: string }) => r.url).join(', ')}` + ) } function decodeHtmlEntities(text: string): string { diff --git a/apps/sim/tools/jira/utils.ts b/apps/sim/tools/jira/utils.ts index 1891eba2459..dbc34086a48 100644 --- a/apps/sim/tools/jira/utils.ts +++ b/apps/sim/tools/jira/utils.ts @@ -97,6 +97,13 @@ export async function downloadJiraAttachments( return downloaded } +function normalizeDomain(domain: string): string { + return `https://${domain + .trim() + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '')}`.toLowerCase() +} + export async function getJiraCloudId(domain: string, accessToken: string): Promise { const response = await fetchWithRetry( 'https://api.atlassian.com/oauth/token/accessible-resources', @@ -116,18 +123,25 @@ export async function getJiraCloudId(domain: string, accessToken: string): Promi const resources = await response.json() - if (Array.isArray(resources) && resources.length > 0) { - const normalizedInput = `https://${domain}`.toLowerCase() - const matchedResource = resources.find((r) => r.url.toLowerCase() === normalizedInput) + if (!Array.isArray(resources) || resources.length === 0) { + throw new Error('No Jira resources found') + } - if (matchedResource) { - return matchedResource.id - } + const normalized = normalizeDomain(domain) + const match = resources.find( + (r: { url: string }) => r.url.toLowerCase().replace(/\/+$/, '') === normalized + ) + + if (match) { + return match.id } - if (Array.isArray(resources) && resources.length > 0) { + if (resources.length === 1) { return resources[0].id } - throw new Error('No Jira resources found') + throw new Error( + `Could not match Jira domain "${domain}" to any accessible resource. ` + + `Available sites: ${resources.map((r: { url: string }) => r.url).join(', ')}` + ) } From 72eea64bf6c5742d106a762b5aae296b4d6415bd Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 16:59:53 -0700 Subject: [PATCH 10/15] improvement(tour): align product tour tooltip styling with emcn and fix spotlight overflow (#3854) --- .../components/product-tour/nav-tour-steps.ts | 2 +- .../components/product-tour/tour-shared.tsx | 20 ++++------ .../components/product-tour/use-tour.ts | 10 +++++ .../w/components/sidebar/sidebar.tsx | 11 ++++-- .../components/tour-tooltip/tour-tooltip.tsx | 38 +++++++++---------- apps/sim/tailwind.config.ts | 6 +++ 6 files changed, 49 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps.ts b/apps/sim/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps.ts index 67446e4db52..c18aae9734e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps.ts @@ -61,7 +61,7 @@ export const navTourSteps: Step[] = [ target: '[data-tour="nav-tasks"]', title: 'Tasks', content: - 'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.', + 'Tasks that work for you. Mothership can create, edit, and delete resources throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.', placement: 'right', disableBeacon: true, }, diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/tour-shared.tsx b/apps/sim/app/workspace/[workspaceId]/components/product-tour/tour-shared.tsx index d3844021281..774ed8ad876 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/tour-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/product-tour/tour-shared.tsx @@ -1,6 +1,6 @@ 'use client' -import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { createContext, useCallback, useContext } from 'react' import type { TooltipRenderProps } from 'react-joyride' import { TourTooltip } from '@/components/emcn' @@ -59,18 +59,14 @@ export function TourTooltipAdapter({ closeProps, }: TooltipRenderProps) { const { isTooltipVisible, isEntrance, totalSteps } = useContext(TourStateContext) - const [targetEl, setTargetEl] = useState(null) - useEffect(() => { - const { target } = step - if (typeof target === 'string') { - setTargetEl(document.querySelector(target)) - } else if (target instanceof HTMLElement) { - setTargetEl(target) - } else { - setTargetEl(null) - } - }, [step]) + const { target } = step + const targetEl = + typeof target === 'string' + ? document.querySelector(target) + : target instanceof HTMLElement + ? target + : null /** * Forwards the Joyride tooltip ref safely, handling both diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts b/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts index 10b09caf9bb..345932b765d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts @@ -114,6 +114,16 @@ export function useTour({ [steps.length, stopTour, cancelPendingTransitions, scheduleReveal] ) + useEffect(() => { + if (!run) return + const html = document.documentElement + const prev = html.style.scrollbarGutter + html.style.scrollbarGutter = 'stable' + return () => { + html.style.scrollbarGutter = prev + } + }, [run]) + /** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */ useEffect(() => { if (disabled && run) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index e3fbffd961a..7c89b20e191 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1329,8 +1329,11 @@ export const Sidebar = memo(function Sidebar() { !hasOverflowTop && 'border-transparent' )} > -
-
+
+
All tasks
{!isCollapsed && (
@@ -1451,10 +1454,10 @@ export const Sidebar = memo(function Sidebar() {
-
+
Workflows
{!isCollapsed && (
diff --git a/apps/sim/components/emcn/components/tour-tooltip/tour-tooltip.tsx b/apps/sim/components/emcn/components/tour-tooltip/tour-tooltip.tsx index 678d1749ba4..ca286249952 100644 --- a/apps/sim/components/emcn/components/tour-tooltip/tour-tooltip.tsx +++ b/apps/sim/components/emcn/components/tour-tooltip/tour-tooltip.tsx @@ -45,12 +45,12 @@ function TourCard({ return ( <>
-

+

{title}

-

{description}

+

{description}

-
- +
+ {step} / {totalSteps}
-
- -
+ @@ -156,7 +155,7 @@ function TourTooltip({ const isCentered = placement === 'center' const cardClasses = cn( - 'w-[260px] overflow-hidden rounded-[8px] bg-[var(--bg)]', + 'w-[260px] overflow-hidden rounded-xl bg-[var(--bg)]', isEntrance && 'animate-tour-tooltip-in motion-reduce:animate-none', className ) @@ -181,7 +180,7 @@ function TourTooltip({
{cardContent} @@ -202,10 +201,7 @@ function TourTooltip({ sideOffset={10} collisionPadding={12} avoidCollisions - className='z-[10000300] outline-none' - style={{ - filter: 'drop-shadow(0 0 0.5px var(--border)) drop-shadow(0 1px 2px rgba(0,0,0,0.1))', - }} + className='z-[10000300] outline-none drop-shadow-tour' onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > diff --git a/apps/sim/tailwind.config.ts b/apps/sim/tailwind.config.ts index 64682d1f2de..315e6ed2ff3 100644 --- a/apps/sim/tailwind.config.ts +++ b/apps/sim/tailwind.config.ts @@ -126,6 +126,12 @@ export default { 'brand-inset': 'var(--shadow-brand-inset)', card: 'var(--shadow-card)', }, + dropShadow: { + tour: [ + '0 0 0.5px color-mix(in srgb, var(--text-primary) 10%, transparent)', + '0 4px 12px rgba(0,0,0,0.1)', + ], + }, transitionProperty: { width: 'width', left: 'left', From e9c94fa46275f7a328e792d933d2816c1ccafa9b Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 18:42:27 -0700 Subject: [PATCH 11/15] feat(logs): add copy link and deep link support for log entries (#3855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(logs): add copy link and deep link support for log entries * fix(logs): fetch next page when deep linked log is beyond initial page * fix(logs): move Link icon to emcn and handle clipboard rejections * fix(logs): track isFetching reactively and drop empty-list early-return - Remove guard that prevented clearing the pending ref when filters return no results - Use directly in the condition and add it to the effect deps so the effect re-triggers after a background refetch * fix(logs): guard deep-link ref clear until query has succeeded Only clear pendingExecutionIdRef when the query status is 'success', preventing premature clearing before the initial fetch completes. On mount, the query is disabled (isInitialized.current starts false), so hasNextPage is false but no data has loaded yet — the ref was being cleared in the same effect pass that set it. * fix(logs): guard fetchNextPage call until query has succeeded Add logsQuery.status === 'success' to the fetchNextPage branch so it mirrors the clear branch. On mount the query is disabled (isFetching is false, status is pending), causing the effect to call fetchNextPage() before the query is initialized — now both branches require success. --- .../log-row-context-menu.tsx | 8 ++- .../app/workspace/[workspaceId]/logs/logs.tsx | 64 +++++++++++-------- apps/sim/components/emcn/icons/index.ts | 1 + apps/sim/components/emcn/icons/link.tsx | 26 ++++++++ 4 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 apps/sim/components/emcn/icons/link.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index c4b232c8dac..0a283a401a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/emcn' -import { Copy, Eye, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons' +import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons' import type { WorkflowLog } from '@/stores/logs/filters/types' interface LogRowContextMenuProps { @@ -17,6 +17,7 @@ interface LogRowContextMenuProps { onClose: () => void log: WorkflowLog | null onCopyExecutionId: () => void + onCopyLink: () => void onOpenWorkflow: () => void onOpenPreview: () => void onToggleWorkflowFilter: () => void @@ -35,6 +36,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ onClose, log, onCopyExecutionId, + onCopyLink, onOpenWorkflow, onOpenPreview, onToggleWorkflowFilter, @@ -71,6 +73,10 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ Copy Execution ID + + + Copy Link + diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 85de3bebd0e..098f23158a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -266,16 +266,17 @@ export default function Logs() { isSidebarOpen: false, }) const isInitialized = useRef(false) + const pendingExecutionIdRef = useRef(null) const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebounce(searchQuery, 300) useEffect(() => { - const urlSearch = new URLSearchParams(window.location.search).get('search') || '' - if (urlSearch && urlSearch !== searchQuery) { - setSearchQuery(urlSearch) - } - // eslint-disable-next-line react-hooks/exhaustive-deps + const params = new URLSearchParams(window.location.search) + const urlSearch = params.get('search') + if (urlSearch) setSearchQuery(urlSearch) + const urlExecutionId = params.get('executionId') + if (urlExecutionId) pendingExecutionIdRef.current = urlExecutionId }, []) const isLive = true @@ -298,7 +299,6 @@ export default function Logs() { const [contextMenuOpen, setContextMenuOpen] = useState(false) const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [contextMenuLog, setContextMenuLog] = useState(null) - const contextMenuRef = useRef(null) const [isPreviewOpen, setIsPreviewOpen] = useState(false) const [previewLogId, setPreviewLogId] = useState(null) @@ -417,28 +417,30 @@ export default function Logs() { useFolders(workspaceId) + logsRef.current = sortedLogs + selectedLogIndexRef.current = selectedLogIndex + selectedLogIdRef.current = selectedLogId + logsRefetchRef.current = logsQuery.refetch + activeLogRefetchRef.current = activeLogQuery.refetch + logsQueryRef.current = { + isFetching: logsQuery.isFetching, + hasNextPage: logsQuery.hasNextPage ?? false, + fetchNextPage: logsQuery.fetchNextPage, + } + useEffect(() => { - logsRef.current = sortedLogs - }, [sortedLogs]) - useEffect(() => { - selectedLogIndexRef.current = selectedLogIndex - }, [selectedLogIndex]) - useEffect(() => { - selectedLogIdRef.current = selectedLogId - }, [selectedLogId]) - useEffect(() => { - logsRefetchRef.current = logsQuery.refetch - }, [logsQuery.refetch]) - useEffect(() => { - activeLogRefetchRef.current = activeLogQuery.refetch - }, [activeLogQuery.refetch]) - useEffect(() => { - logsQueryRef.current = { - isFetching: logsQuery.isFetching, - hasNextPage: logsQuery.hasNextPage ?? false, - fetchNextPage: logsQuery.fetchNextPage, + if (!pendingExecutionIdRef.current) return + const targetExecutionId = pendingExecutionIdRef.current + const found = sortedLogs.find((l) => l.executionId === targetExecutionId) + if (found) { + pendingExecutionIdRef.current = null + dispatch({ type: 'TOGGLE_LOG', logId: found.id }) + } else if (!logsQuery.hasNextPage && logsQuery.status === 'success') { + pendingExecutionIdRef.current = null + } else if (!logsQuery.isFetching && logsQuery.status === 'success') { + logsQueryRef.current.fetchNextPage() } - }, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage]) + }, [sortedLogs, logsQuery.hasNextPage, logsQuery.isFetching, logsQuery.status]) useEffect(() => { const timers = refreshTimersRef.current @@ -490,10 +492,17 @@ export default function Logs() { const handleCopyExecutionId = useCallback(() => { if (contextMenuLog?.executionId) { - navigator.clipboard.writeText(contextMenuLog.executionId) + navigator.clipboard.writeText(contextMenuLog.executionId).catch(() => {}) } }, [contextMenuLog]) + const handleCopyLink = useCallback(() => { + if (contextMenuLog?.executionId) { + const url = `${window.location.origin}/workspace/${workspaceId}/logs?executionId=${contextMenuLog.executionId}` + navigator.clipboard.writeText(url).catch(() => {}) + } + }, [contextMenuLog, workspaceId]) + const handleOpenWorkflow = useCallback(() => { const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId if (wfId) { @@ -1165,6 +1174,7 @@ export default function Logs() { onClose={handleCloseContextMenu} log={contextMenuLog} onCopyExecutionId={handleCopyExecutionId} + onCopyLink={handleCopyLink} onOpenWorkflow={handleOpenWorkflow} onOpenPreview={handleOpenPreview} onToggleWorkflowFilter={handleToggleWorkflowFilter} diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index 0e97f5bce57..bf109bf5ba0 100644 --- a/apps/sim/components/emcn/icons/index.ts +++ b/apps/sim/components/emcn/icons/index.ts @@ -42,6 +42,7 @@ export { Key } from './key' export { KeySquare } from './key-square' export { Layout } from './layout' export { Library } from './library' +export { Link } from './link' export { ListFilter } from './list-filter' export { Loader } from './loader' export { Lock } from './lock' diff --git a/apps/sim/components/emcn/icons/link.tsx b/apps/sim/components/emcn/icons/link.tsx new file mode 100644 index 00000000000..46ae6a130ea --- /dev/null +++ b/apps/sim/components/emcn/icons/link.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +/** + * Link icon component + * @param props - SVG properties including className, size, etc. + */ +export function Link(props: SVGProps) { + return ( + + ) +} From 0abeac77e1d0d87de52cc8f83d9807c5573160f4 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 30 Mar 2026 20:25:38 -0700 Subject: [PATCH 12/15] improvement(platform): standardize perms, audit logging, lifecycle across admin, copilot, ui actions (#3858) * improvement(platform): standardize perms, audit logging, lifecycle mgmt across admin, copilot, ui actions * address comments * improve error codes * address bugbot comments * fix test --- .../app/api/chat/manage/[id]/route.test.ts | 22 +- apps/sim/app/api/chat/manage/[id]/route.ts | 33 +- apps/sim/app/api/chat/route.test.ts | 79 +- apps/sim/app/api/chat/route.ts | 122 +- apps/sim/app/api/folders/[id]/route.test.ts | 42 +- apps/sim/app/api/folders/[id]/route.ts | 176 +-- apps/sim/app/api/skills/route.ts | 22 + apps/sim/app/api/tools/custom/route.ts | 22 + .../v1/admin/workflows/[id]/deploy/route.ts | 185 +-- .../app/api/v1/admin/workflows/[id]/route.ts | 18 +- .../versions/[versionId]/activate/route.ts | 154 +-- .../app/api/workflows/[id]/deploy/route.ts | 251 +--- .../[id]/deployments/[version]/route.ts | 167 +-- apps/sim/app/api/workflows/[id]/route.test.ts | 57 +- apps/sim/app/api/workflows/[id]/route.ts | 70 +- apps/sim/lib/audit/log.ts | 16 +- .../lib/copilot/client-sse/content-blocks.ts | 62 - apps/sim/lib/copilot/client-sse/handlers.ts | 1115 ----------------- .../orchestrator/tool-executor/access.ts | 29 +- .../tool-executor/deployment-tools/deploy.ts | 154 ++- .../tool-executor/deployment-tools/manage.ts | 46 +- .../orchestrator/tool-executor/index.ts | 116 +- .../orchestrator/tool-executor/job-tools.ts | 45 + .../tool-executor/materialize-file.ts | 12 + .../tool-executor/workflow-tools/mutations.ts | 135 +- .../tool-executor/workflow-tools/queries.ts | 2 +- .../workflows/orchestration/chat-deploy.ts | 206 +++ .../sim/lib/workflows/orchestration/deploy.ts | 484 +++++++ .../orchestration/folder-lifecycle.ts | 155 +++ apps/sim/lib/workflows/orchestration/index.ts | 30 + apps/sim/lib/workflows/orchestration/types.ts | 1 + .../orchestration/workflow-lifecycle.ts | 118 ++ apps/sim/lib/workflows/utils.ts | 30 + packages/testing/src/mocks/audit.mock.ts | 10 + 34 files changed, 1773 insertions(+), 2413 deletions(-) delete mode 100644 apps/sim/lib/copilot/client-sse/content-blocks.ts delete mode 100644 apps/sim/lib/copilot/client-sse/handlers.ts create mode 100644 apps/sim/lib/workflows/orchestration/chat-deploy.ts create mode 100644 apps/sim/lib/workflows/orchestration/deploy.ts create mode 100644 apps/sim/lib/workflows/orchestration/folder-lifecycle.ts create mode 100644 apps/sim/lib/workflows/orchestration/index.ts create mode 100644 apps/sim/lib/workflows/orchestration/types.ts create mode 100644 apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 48e65146db5..5808f2cbb58 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -15,12 +15,12 @@ const { mockLimit, mockUpdate, mockSet, - mockDelete, mockCreateSuccessResponse, mockCreateErrorResponse, mockEncryptSecret, mockCheckChatAccess, mockDeployWorkflow, + mockPerformChatUndeploy, mockLogger, } = vi.hoisted(() => { const logger = { @@ -40,12 +40,12 @@ const { mockLimit: vi.fn(), mockUpdate: vi.fn(), mockSet: vi.fn(), - mockDelete: vi.fn(), mockCreateSuccessResponse: vi.fn(), mockCreateErrorResponse: vi.fn(), mockEncryptSecret: vi.fn(), mockCheckChatAccess: vi.fn(), mockDeployWorkflow: vi.fn(), + mockPerformChatUndeploy: vi.fn(), mockLogger: logger, } }) @@ -66,7 +66,6 @@ vi.mock('@sim/db', () => ({ db: { select: mockSelect, update: mockUpdate, - delete: mockDelete, }, })) vi.mock('@sim/db/schema', () => ({ @@ -88,6 +87,9 @@ vi.mock('@/app/api/chat/utils', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ deployWorkflow: mockDeployWorkflow, })) +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatUndeploy: mockPerformChatUndeploy, +})) vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), @@ -106,7 +108,7 @@ describe('Chat Edit API Route', () => { mockWhere.mockReturnValue({ limit: mockLimit }) mockUpdate.mockReturnValue({ set: mockSet }) mockSet.mockReturnValue({ where: mockWhere }) - mockDelete.mockReturnValue({ where: mockWhere }) + mockPerformChatUndeploy.mockResolvedValue({ success: true }) mockCreateSuccessResponse.mockImplementation((data) => { return new Response(JSON.stringify(data), { @@ -428,7 +430,11 @@ describe('Chat Edit API Route', () => { const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) expect(response.status).toBe(200) - expect(mockDelete).toHaveBeenCalled() + expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ + chatId: 'chat-123', + userId: 'user-id', + workspaceId: 'workspace-123', + }) const data = await response.json() expect(data.message).toBe('Chat deployment deleted successfully') }) @@ -451,7 +457,11 @@ describe('Chat Edit API Route', () => { expect(response.status).toBe(200) expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id') - expect(mockDelete).toHaveBeenCalled() + expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ + chatId: 'chat-123', + userId: 'admin-user-id', + workspaceId: 'workspace-123', + }) }) }) }) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 1585d380057..c09688c99d6 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' +import { performChatUndeploy } from '@/lib/workflows/orchestration' import { deployWorkflow } from '@/lib/workflows/persistence/utils' import { checkChatAccess } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -270,33 +271,25 @@ export async function DELETE( return createErrorResponse('Unauthorized', 401) } - const { - hasAccess, - chat: chatRecord, - workspaceId: chatWorkspaceId, - } = await checkChatAccess(chatId, session.user.id) + const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess( + chatId, + session.user.id + ) if (!hasAccess) { return createErrorResponse('Chat not found or access denied', 404) } - await db.delete(chat).where(eq(chat.id, chatId)) - - logger.info(`Chat "${chatId}" deleted successfully`) - - recordAudit({ - workspaceId: chatWorkspaceId || null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CHAT_DELETED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: chatRecord?.title || chatId, - description: `Deleted chat deployment "${chatRecord?.title || chatId}"`, - request: _request, + const result = await performChatUndeploy({ + chatId, + userId: session.user.id, + workspaceId: chatWorkspaceId, }) + if (!result.success) { + return createErrorResponse(result.error || 'Failed to delete chat', 500) + } + return createSuccessResponse({ message: 'Chat deployment deleted successfully', }) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index d590d6e506b..47840515e98 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { auditMock, createEnvMock } from '@sim/testing' +import { createEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,66 +12,51 @@ const { mockFrom, mockWhere, mockLimit, - mockInsert, - mockValues, - mockReturning, mockCreateSuccessResponse, mockCreateErrorResponse, - mockEncryptSecret, mockCheckWorkflowAccessForChatCreation, - mockDeployWorkflow, + mockPerformChatDeploy, mockGetSession, - mockUuidV4, } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockFrom: vi.fn(), mockWhere: vi.fn(), mockLimit: vi.fn(), - mockInsert: vi.fn(), - mockValues: vi.fn(), - mockReturning: vi.fn(), mockCreateSuccessResponse: vi.fn(), mockCreateErrorResponse: vi.fn(), - mockEncryptSecret: vi.fn(), mockCheckWorkflowAccessForChatCreation: vi.fn(), - mockDeployWorkflow: vi.fn(), + mockPerformChatDeploy: vi.fn(), mockGetSession: vi.fn(), - mockUuidV4: vi.fn(), })) -vi.mock('@/lib/audit/log', () => auditMock) - vi.mock('@sim/db', () => ({ db: { select: mockSelect, - insert: mockInsert, }, })) vi.mock('@sim/db/schema', () => ({ - chat: { userId: 'userId', identifier: 'identifier' }, + chat: { userId: 'userId', identifier: 'identifier', archivedAt: 'archivedAt' }, workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' }, })) +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), +})) + vi.mock('@/app/api/workflows/utils', () => ({ createSuccessResponse: mockCreateSuccessResponse, createErrorResponse: mockCreateErrorResponse, })) -vi.mock('@/lib/core/security/encryption', () => ({ - encryptSecret: mockEncryptSecret, -})) - -vi.mock('uuid', () => ({ - v4: mockUuidV4, -})) - vi.mock('@/app/api/chat/utils', () => ({ checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, })) -vi.mock('@/lib/workflows/persistence/utils', () => ({ - deployWorkflow: mockDeployWorkflow, +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatDeploy: mockPerformChatDeploy, })) vi.mock('@/lib/auth', () => ({ @@ -94,10 +79,6 @@ describe('Chat API Route', () => { mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockWhere.mockReturnValue({ limit: mockLimit }) - mockInsert.mockReturnValue({ values: mockValues }) - mockValues.mockReturnValue({ returning: mockReturning }) - - mockUuidV4.mockReturnValue('test-uuid') mockCreateSuccessResponse.mockImplementation((data) => { return new Response(JSON.stringify(data), { @@ -113,12 +94,10 @@ describe('Chat API Route', () => { }) }) - mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) - - mockDeployWorkflow.mockResolvedValue({ + mockPerformChatDeploy.mockResolvedValue({ success: true, - version: 1, - deployedAt: new Date(), + chatId: 'test-uuid', + chatUrl: 'http://localhost:3000/chat/test-chat', }) }) @@ -277,7 +256,6 @@ describe('Chat API Route', () => { hasAccess: true, workflow: { userId: 'user-id', workspaceId: null, isDeployed: true }, }) - mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', @@ -287,6 +265,13 @@ describe('Chat API Route', () => { expect(response.status).toBe(200) expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') + expect(mockPerformChatDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-123', + userId: 'user-id', + identifier: 'test-chat', + }) + ) }) it('should allow chat deployment when user has workspace admin permission', async () => { @@ -309,7 +294,6 @@ describe('Chat API Route', () => { hasAccess: true, workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true }, }) - mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', @@ -319,6 +303,12 @@ describe('Chat API Route', () => { expect(response.status).toBe(200) expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') + expect(mockPerformChatDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-123', + workspaceId: 'workspace-123', + }) + ) }) it('should reject when workflow is in workspace but user lacks admin permission', async () => { @@ -383,7 +373,7 @@ describe('Chat API Route', () => { expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') }) - it('should auto-deploy workflow if not already deployed', async () => { + it('should call performChatDeploy for undeployed workflow', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-id', email: 'user@example.com' }, }) @@ -403,7 +393,6 @@ describe('Chat API Route', () => { hasAccess: true, workflow: { userId: 'user-id', workspaceId: null, isDeployed: false }, }) - mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', @@ -412,10 +401,12 @@ describe('Chat API Route', () => { const response = await POST(req) expect(response.status).toBe(200) - expect(mockDeployWorkflow).toHaveBeenCalledWith({ - workflowId: 'workflow-123', - deployedBy: 'user-id', - }) + expect(mockPerformChatDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-123', + userId: 'user-id', + }) + ) }) }) }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index b7233238017..c9528715d9d 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -3,14 +3,9 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' -import { isDev } from '@/lib/core/config/feature-flags' -import { encryptSecret } from '@/lib/core/security/encryption' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { performChatDeploy } from '@/lib/workflows/orchestration' import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -109,7 +104,6 @@ export async function POST(request: NextRequest) { ) } - // Check identifier availability and workflow access in parallel const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ db .select() @@ -127,121 +121,27 @@ export async function POST(request: NextRequest) { return createErrorResponse('Workflow not found or access denied', 404) } - // Always deploy/redeploy the workflow to ensure latest version - const result = await deployWorkflow({ - workflowId, - deployedBy: session.user.id, - }) - - if (!result.success) { - return createErrorResponse(result.error || 'Failed to deploy workflow', 500) - } - - logger.info( - `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})` - ) - - // Encrypt password if provided - let encryptedPassword = null - if (authType === 'password' && password) { - const { encrypted } = await encryptSecret(password) - encryptedPassword = encrypted - } - - // Create the chat deployment - const id = uuidv4() - - // Log the values we're inserting - logger.info('Creating chat deployment with values:', { - workflowId, - identifier, - title, - authType, - hasPassword: !!encryptedPassword, - emailCount: allowedEmails?.length || 0, - outputConfigsCount: outputConfigs.length, - }) - - // Merge customizations with the additional fields - const mergedCustomizations = { - ...(customizations || {}), - primaryColor: customizations?.primaryColor || 'var(--brand-hover)', - welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?', - } - - await db.insert(chat).values({ - id, + const result = await performChatDeploy({ workflowId, userId: session.user.id, identifier, title, - description: description || null, - customizations: mergedCustomizations, - isActive: true, + description, + customizations, authType, - password: encryptedPassword, - allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], + password, + allowedEmails, outputConfigs, - createdAt: new Date(), - updatedAt: new Date(), + workspaceId: workflowRecord.workspaceId, }) - // Return successful response with chat URL - // Generate chat URL using path-based routing instead of subdomains - const baseUrl = getBaseUrl() - - let chatUrl: string - try { - const url = new URL(baseUrl) - let host = url.host - if (host.startsWith('www.')) { - host = host.substring(4) - } - chatUrl = `${url.protocol}//${host}/chat/${identifier}` - } catch (error) { - logger.warn('Failed to parse baseUrl, falling back to defaults:', { - baseUrl, - error: error instanceof Error ? error.message : 'Unknown error', - }) - // Fallback based on environment - if (isDev) { - chatUrl = `http://localhost:3000/chat/${identifier}` - } else { - chatUrl = `https://sim.ai/chat/${identifier}` - } - } - - logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.chatDeployed({ - chatId: id, - workflowId, - authType, - hasOutputConfigs: outputConfigs.length > 0, - }) - } catch (_e) { - // Silently fail + if (!result.success) { + return createErrorResponse(result.error || 'Failed to deploy chat', 500) } - recordAudit({ - workspaceId: workflowRecord.workspaceId || null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CHAT_DEPLOYED, - resourceType: AuditResourceType.CHAT, - resourceId: id, - resourceName: title, - description: `Deployed chat "${title}"`, - metadata: { workflowId, identifier, authType }, - request, - }) - return createSuccessResponse({ - id, - chatUrl, + id: result.chatId, + chatUrl: result.chatUrl, message: 'Chat deployment created successfully', }) } catch (validationError) { diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index ecdcc2c4b5a..ee13e3a6c3f 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -6,7 +6,14 @@ import { auditMock, createMockRequest, type MockUser } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } = vi.hoisted(() => { +const { + mockGetSession, + mockGetUserEntityPermissions, + mockLogger, + mockDbRef, + mockPerformDeleteFolder, + mockCheckForCircularReference, +} = vi.hoisted(() => { const logger = { info: vi.fn(), warn: vi.fn(), @@ -21,6 +28,8 @@ const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } = mockGetUserEntityPermissions: vi.fn(), mockLogger: logger, mockDbRef: { current: null as any }, + mockPerformDeleteFolder: vi.fn(), + mockCheckForCircularReference: vi.fn(), } }) @@ -39,6 +48,12 @@ vi.mock('@sim/db', () => ({ return mockDbRef.current }, })) +vi.mock('@/lib/workflows/orchestration', () => ({ + performDeleteFolder: mockPerformDeleteFolder, +})) +vi.mock('@/lib/workflows/utils', () => ({ + checkForCircularReference: mockCheckForCircularReference, +})) import { DELETE, PUT } from '@/app/api/folders/[id]/route' @@ -144,6 +159,11 @@ describe('Individual Folder API Route', () => { mockGetUserEntityPermissions.mockResolvedValue('admin') mockDbRef.current = createFolderDbMock() + mockPerformDeleteFolder.mockResolvedValue({ + success: true, + deletedItems: { folders: 1, workflows: 0 }, + }) + mockCheckForCircularReference.mockResolvedValue(false) }) describe('PUT /api/folders/[id]', () => { @@ -369,13 +389,17 @@ describe('Individual Folder API Route', () => { it('should prevent circular references when updating parent', async () => { mockAuthenticatedUser() - const circularCheckResults = [{ parentId: 'folder-2' }, { parentId: 'folder-3' }] - mockDbRef.current = createFolderDbMock({ - folderLookupResult: { id: 'folder-3', parentId: null, name: 'Folder 3' }, - circularCheckResults, + folderLookupResult: { + id: 'folder-3', + parentId: null, + name: 'Folder 3', + workspaceId: 'workspace-123', + }, }) + mockCheckForCircularReference.mockResolvedValue(true) + const req = createMockRequest('PUT', { name: 'Updated Folder 3', parentId: 'folder-1', @@ -388,6 +412,7 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('error', 'Cannot create circular folder reference') + expect(mockCheckForCircularReference).toHaveBeenCalledWith('folder-3', 'folder-1') }) }) @@ -409,6 +434,12 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('success', true) expect(data).toHaveProperty('deletedItems') + expect(mockPerformDeleteFolder).toHaveBeenCalledWith({ + folderId: 'folder-1', + workspaceId: 'workspace-123', + userId: TEST_USER.id, + folderName: 'Test Folder', + }) }) it('should return 401 for unauthenticated delete requests', async () => { @@ -472,6 +503,7 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('success', true) + expect(mockPerformDeleteFolder).toHaveBeenCalled() }) it('should handle database errors during deletion', async () => { diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 41b9a6276cb..cc25ecd770e 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' -import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' +import { performDeleteFolder } from '@/lib/workflows/orchestration' +import { checkForCircularReference } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersIDAPI') @@ -130,7 +130,6 @@ export async function DELETE( return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) } - // Check if user has admin permissions for the workspace (admin-only for deletions) const workspacePermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -144,170 +143,25 @@ export async function DELETE( ) } - // Check if deleting this folder would delete the last workflow(s) in the workspace - const workflowsInFolder = await countWorkflowsInFolderRecursively( - id, - existingFolder.workspaceId - ) - const totalWorkflowsInWorkspace = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.workspaceId, existingFolder.workspaceId), isNull(workflow.archivedAt))) - - if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) { - return NextResponse.json( - { error: 'Cannot delete folder containing the only workflow(s) in the workspace' }, - { status: 400 } - ) - } - - // Recursively delete folder and all its contents - const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId) - - logger.info('Deleted folder and all contents:', { - id, - deletionStats, - }) - - recordAudit({ + const result = await performDeleteFolder({ + folderId: id, workspaceId: existingFolder.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: id, - resourceName: existingFolder.name, - description: `Deleted folder "${existingFolder.name}"`, - metadata: { - affected: { - workflows: deletionStats.workflows, - subfolders: deletionStats.folders - 1, - }, - }, - request, + userId: session.user.id, + folderName: existingFolder.name, }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) + } + return NextResponse.json({ success: true, - deletedItems: deletionStats, + deletedItems: result.deletedItems, }) } catch (error) { logger.error('Error deleting folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } - -// Helper function to recursively delete a folder and all its contents -async function deleteFolderRecursively( - folderId: string, - workspaceId: string -): Promise<{ folders: number; workflows: number }> { - const stats = { folders: 0, workflows: 0 } - - // Get all child folders first (workspace-scoped, not user-scoped) - const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) - .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) - - // Recursively delete child folders - for (const childFolder of childFolders) { - const childStats = await deleteFolderRecursively(childFolder.id, workspaceId) - stats.folders += childStats.folders - stats.workflows += childStats.workflows - } - - // Delete all workflows in this folder (workspace-scoped, not user-scoped) - // The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows - const workflowsInFolder = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.folderId, folderId), - eq(workflow.workspaceId, workspaceId), - isNull(workflow.archivedAt) - ) - ) - - if (workflowsInFolder.length > 0) { - await archiveWorkflowsByIdsInWorkspace( - workspaceId, - workflowsInFolder.map((entry) => entry.id), - { requestId: `folder-${folderId}` } - ) - - stats.workflows += workflowsInFolder.length - } - - // Delete this folder - await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId)) - - stats.folders += 1 - - return stats -} - -/** - * Counts the number of workflows in a folder and all its subfolders recursively. - */ -async function countWorkflowsInFolderRecursively( - folderId: string, - workspaceId: string -): Promise { - let count = 0 - - const workflowsInFolder = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.folderId, folderId), - eq(workflow.workspaceId, workspaceId), - isNull(workflow.archivedAt) - ) - ) - - count += workflowsInFolder.length - - const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) - .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) - - for (const childFolder of childFolders) { - count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId) - } - - return count -} - -// Helper function to check for circular references -async function checkForCircularReference(folderId: string, parentId: string): Promise { - let currentParentId: string | null = parentId - const visited = new Set() - - while (currentParentId) { - if (visited.has(currentParentId)) { - return true // Circular reference detected - } - - if (currentParentId === folderId) { - return true // Would create a cycle - } - - visited.add(currentParentId) - - // Get the parent of the current parent - const parent: { parentId: string | null } | undefined = await db - .select({ parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentParentId)) - .then((rows) => rows[0]) - - currentParentId = parent?.parentId || null - } - - return false -} diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 224edf44dee..a45539d20cc 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' @@ -96,6 +97,18 @@ export async function POST(req: NextRequest) { requestId, }) + for (const skill of resultSkills) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.SKILL_CREATED, + resourceType: AuditResourceType.SKILL, + resourceId: skill.id, + resourceName: skill.name, + description: `Created/updated skill "${skill.name}"`, + }) + } + return NextResponse.json({ success: true, data: resultSkills }) } catch (validationError) { if (validationError instanceof z.ZodError) { @@ -158,6 +171,15 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) } + recordAudit({ + workspaceId, + actorId: authResult.userId, + action: AuditAction.SKILL_DELETED, + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + description: `Deleted skill`, + }) + logger.info(`[${requestId}] Deleted skill: ${skillId}`) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index 9979a378f37..6bcbf553067 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations' @@ -166,6 +167,18 @@ export async function POST(req: NextRequest) { requestId, }) + for (const tool of resultTools) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: tool.id, + resourceName: tool.title, + description: `Created/updated custom tool "${tool.title}"`, + }) + } + return NextResponse.json({ success: true, data: resultTools }) } catch (validationError) { if (validationError instanceof z.ZodError) { @@ -265,6 +278,15 @@ export async function DELETE(request: NextRequest) { // Delete the tool await db.delete(customTools).where(eq(customTools.id, toolId)) + recordAudit({ + workspaceId: tool.workspaceId || undefined, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: toolId, + description: `Deleted custom tool`, + }) + logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index 9a6eeba491b..d6b195fe78f 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -1,25 +1,7 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' -import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { - cleanupWebhooksForWorkflow, - restorePreviousVersionWebhooks, - saveTriggerWebhooksForDeploy, -} from '@/lib/webhooks/deploy' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' -import { - activateWorkflowVersionById, - deployWorkflow, - loadWorkflowFromNormalizedTables, - undeployWorkflow, -} from '@/lib/workflows/persistence/utils' -import { - cleanupDeploymentVersion, - createSchedulesForDeploy, - validateWorkflowSchedules, -} from '@/lib/workflows/schedules' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -31,12 +13,19 @@ import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/ const logger = createLogger('AdminWorkflowDeployAPI') -const ADMIN_ACTOR_ID = 'admin-api' - interface RouteParams { id: string } +/** + * POST — Deploy a workflow via admin API. + * + * `userId` is set to the workflow owner so that webhook credential resolution + * (OAuth token lookups for providers like Airtable, Attio, etc.) uses a real + * user. `actorId` is set to `'admin-api'` so that the `deployedBy` field on + * the deployment version and audit log entries are correctly attributed to an + * admin action rather than the workflow owner. + */ export const POST = withAdminAuthParams(async (request, context) => { const { id: workflowId } = await context.params const requestId = generateRequestId() @@ -48,140 +37,28 @@ export const POST = withAdminAuthParams(async (request, context) => return notFoundResponse('Workflow') } - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalizedData) { - return badRequestResponse('Workflow has no saved state') - } - - const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) - if (!scheduleValidation.isValid) { - return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`) - } - - const [currentActiveVersion] = await db - .select({ id: workflowDeploymentVersion.id }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - const previousVersionId = currentActiveVersion?.id - - const rollbackDeployment = async () => { - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData, - userId: workflowRecord.userId, - previousVersionId, - requestId, - }) - const reactivateResult = await activateWorkflowVersionById({ - workflowId, - deploymentVersionId: previousVersionId, - }) - if (reactivateResult.success) { - return - } - } - - await undeployWorkflow({ workflowId }) - } - - const deployResult = await deployWorkflow({ + const result = await performFullDeploy({ workflowId, - deployedBy: ADMIN_ACTOR_ID, - workflowName: workflowRecord.name, - }) - - if (!deployResult.success) { - return internalErrorResponse(deployResult.error || 'Failed to deploy workflow') - } - - if (!deployResult.deploymentVersionId) { - await undeployWorkflow({ workflowId }) - return internalErrorResponse('Failed to resolve deployment version') - } - - const workflowData = workflowRecord as Record - - const triggerSaveResult = await saveTriggerWebhooksForDeploy({ - request, - workflowId, - workflow: workflowData, userId: workflowRecord.userId, - blocks: normalizedData.blocks, + workflowName: workflowRecord.name, requestId, - deploymentVersionId: deployResult.deploymentVersionId, - previousVersionId, + request, + actorId: 'admin-api', }) - if (!triggerSaveResult.success) { - await cleanupDeploymentVersion({ - workflowId, - workflow: workflowData, - requestId, - deploymentVersionId: deployResult.deploymentVersionId, - }) - await rollbackDeployment() - return internalErrorResponse( - triggerSaveResult.error?.message || 'Failed to sync trigger configuration' - ) - } - - const scheduleResult = await createSchedulesForDeploy( - workflowId, - normalizedData.blocks, - db, - deployResult.deploymentVersionId - ) - if (!scheduleResult.success) { - logger.error( - `[${requestId}] Admin API: Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}` - ) - await cleanupDeploymentVersion({ - workflowId, - workflow: workflowData, - requestId, - deploymentVersionId: deployResult.deploymentVersionId, - }) - await rollbackDeployment() - return internalErrorResponse(scheduleResult.error || 'Failed to create schedule') - } - - if (previousVersionId && previousVersionId !== deployResult.deploymentVersionId) { - try { - logger.info(`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId}`) - await cleanupDeploymentVersion({ - workflowId, - workflow: workflowData, - requestId, - deploymentVersionId: previousVersionId, - skipExternalCleanup: true, - }) - } catch (cleanupError) { - logger.error( - `[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`, - cleanupError - ) - } + if (!result.success) { + if (result.errorCode === 'not_found') return notFoundResponse('Workflow state') + if (result.errorCode === 'validation') return badRequestResponse(result.error!) + return internalErrorResponse(result.error || 'Failed to deploy workflow') } - logger.info( - `[${requestId}] Admin API: Deployed workflow ${workflowId} as v${deployResult.version}` - ) - - // Sync MCP tools with the latest parameter schema - await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' }) + logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`) const response: AdminDeployResult = { isDeployed: true, - version: deployResult.version!, - deployedAt: deployResult.deployedAt!.toISOString(), - warnings: triggerSaveResult.warnings, + version: result.version!, + deployedAt: result.deployedAt!.toISOString(), + warnings: result.warnings, } return singleResponse(response) @@ -191,7 +68,7 @@ export const POST = withAdminAuthParams(async (request, context) => } }) -export const DELETE = withAdminAuthParams(async (request, context) => { +export const DELETE = withAdminAuthParams(async (_request, context) => { const { id: workflowId } = await context.params const requestId = generateRequestId() @@ -202,19 +79,17 @@ export const DELETE = withAdminAuthParams(async (request, context) return notFoundResponse('Workflow') } - const result = await undeployWorkflow({ workflowId }) + const result = await performFullUndeploy({ + workflowId, + userId: workflowRecord.userId, + requestId, + actorId: 'admin-api', + }) + if (!result.success) { return internalErrorResponse(result.error || 'Failed to undeploy workflow') } - await cleanupWebhooksForWorkflow( - workflowId, - workflowRecord as Record, - requestId - ) - - await removeMcpToolsForWorkflow(workflowId, requestId) - logger.info(`Admin API: Undeployed workflow ${workflowId}`) const response: AdminUndeployResult = { diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts index ad8644aa49a..927e6fee9d1 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts @@ -13,12 +13,12 @@ */ import { db } from '@sim/db' -import { templates, workflowBlocks, workflowEdges } from '@sim/db/schema' +import { workflowBlocks, workflowEdges } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' -import { archiveWorkflow } from '@/lib/workflows/lifecycle' +import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -69,7 +69,7 @@ export const GET = withAdminAuthParams(async (request, context) => } }) -export const DELETE = withAdminAuthParams(async (request, context) => { +export const DELETE = withAdminAuthParams(async (_request, context) => { const { id: workflowId } = await context.params try { @@ -79,12 +79,18 @@ export const DELETE = withAdminAuthParams(async (request, context) return notFoundResponse('Workflow') } - await db.update(templates).set({ workflowId: null }).where(eq(templates.workflowId, workflowId)) - - await archiveWorkflow(workflowId, { + const result = await performDeleteWorkflow({ + workflowId, + userId: workflowData.userId, + skipLastWorkflowGuard: true, requestId: `admin-workflow-${workflowId}`, + actorId: 'admin-api', }) + if (!result.success) { + return internalErrorResponse(result.error || 'Failed to delete workflow') + } + logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`) return NextResponse.json({ success: true, workflowId }) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index 1824c6508f4..418390592fd 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -1,16 +1,7 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' -import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' -import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils' -import { - cleanupDeploymentVersion, - createSchedulesForDeploy, - validateWorkflowSchedules, -} from '@/lib/workflows/schedules' +import { performActivateVersion } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -18,7 +9,6 @@ import { notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('AdminWorkflowActivateVersionAPI') @@ -43,144 +33,22 @@ export const POST = withAdminAuthParams(async (request, context) => return badRequestResponse('Invalid version number') } - const [versionRow] = await db - .select({ - id: workflowDeploymentVersion.id, - state: workflowDeploymentVersion.state, - }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, versionNum) - ) - ) - .limit(1) - - if (!versionRow?.state) { - return notFoundResponse('Deployment version') - } - - const [currentActiveVersion] = await db - .select({ id: workflowDeploymentVersion.id }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - - const previousVersionId = currentActiveVersion?.id - - const deployedState = versionRow.state as { blocks?: Record } - const blocks = deployedState.blocks - if (!blocks || typeof blocks !== 'object') { - return internalErrorResponse('Invalid deployed state structure') - } - - const workflowData = workflowRecord as Record - - const scheduleValidation = validateWorkflowSchedules(blocks) - if (!scheduleValidation.isValid) { - return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`) - } - - const triggerSaveResult = await saveTriggerWebhooksForDeploy({ - request, + const result = await performActivateVersion({ workflowId, - workflow: workflowData, + version: versionNum, userId: workflowRecord.userId, - blocks, + workflow: workflowRecord as Record, requestId, - deploymentVersionId: versionRow.id, - previousVersionId, - forceRecreateSubscriptions: true, + request, + actorId: 'admin-api', }) - if (!triggerSaveResult.success) { - logger.error( - `[${requestId}] Admin API: Failed to sync triggers for workflow ${workflowId}`, - triggerSaveResult.error - ) - return internalErrorResponse( - triggerSaveResult.error?.message || 'Failed to sync trigger configuration' - ) - } - - const scheduleResult = await createSchedulesForDeploy(workflowId, blocks, db, versionRow.id) - - if (!scheduleResult.success) { - await cleanupDeploymentVersion({ - workflowId, - workflow: workflowData, - requestId, - deploymentVersionId: versionRow.id, - }) - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData, - userId: workflowRecord.userId, - previousVersionId, - requestId, - }) - } - return internalErrorResponse(scheduleResult.error || 'Failed to sync schedules') - } - - const result = await activateWorkflowVersion({ workflowId, version: versionNum }) if (!result.success) { - await cleanupDeploymentVersion({ - workflowId, - workflow: workflowData, - requestId, - deploymentVersionId: versionRow.id, - }) - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData, - userId: workflowRecord.userId, - previousVersionId, - requestId, - }) - } - if (result.error === 'Deployment version not found') { - return notFoundResponse('Deployment version') - } + if (result.errorCode === 'not_found') return notFoundResponse('Deployment version') + if (result.errorCode === 'validation') return badRequestResponse(result.error!) return internalErrorResponse(result.error || 'Failed to activate version') } - if (previousVersionId && previousVersionId !== versionRow.id) { - try { - logger.info( - `[${requestId}] Admin API: Cleaning up previous version ${previousVersionId} webhooks/schedules` - ) - await cleanupDeploymentVersion({ - workflowId, - workflow: workflowData, - requestId, - deploymentVersionId: previousVersionId, - skipExternalCleanup: true, - }) - logger.info(`[${requestId}] Admin API: Previous version cleanup completed`) - } catch (cleanupError) { - logger.error( - `[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`, - cleanupError - ) - } - } - - await syncMcpToolsForWorkflow({ - workflowId, - requestId, - state: versionRow.state, - context: 'activate', - }) - logger.info( `[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}` ) @@ -189,14 +57,12 @@ export const POST = withAdminAuthParams(async (request, context) => success: true, version: versionNum, deployedAt: result.deployedAt!.toISOString(), - warnings: triggerSaveResult.warnings, + warnings: result.warnings, }) } catch (error) { logger.error( `[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`, - { - error, - } + { error } ) return internalErrorResponse('Failed to activate deployment version') } diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index fe84eda30c1..c4f6d0087af 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,26 +1,9 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' -import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { - cleanupWebhooksForWorkflow, - restorePreviousVersionWebhooks, - saveTriggerWebhooksForDeploy, -} from '@/lib/webhooks/deploy' -import { - activateWorkflowVersionById, - deployWorkflow, - loadWorkflowFromNormalizedTables, - undeployWorkflow, -} from '@/lib/workflows/persistence/utils' -import { - cleanupDeploymentVersion, - createSchedulesForDeploy, - validateWorkflowSchedules, -} from '@/lib/workflows/schedules' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, @@ -97,164 +80,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse('Unable to determine deploying user', 400) } - const normalizedData = await loadWorkflowFromNormalizedTables(id) - if (!normalizedData) { - return createErrorResponse('Failed to load workflow state', 500) - } - - const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) - if (!scheduleValidation.isValid) { - logger.warn( - `[${requestId}] Schedule validation failed for workflow ${id}: ${scheduleValidation.error}` - ) - return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400) - } - - const [currentActiveVersion] = await db - .select({ id: workflowDeploymentVersion.id }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - const previousVersionId = currentActiveVersion?.id - - const rollbackDeployment = async () => { - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData as Record, - userId: actorUserId, - previousVersionId, - requestId, - }) - const reactivateResult = await activateWorkflowVersionById({ - workflowId: id, - deploymentVersionId: previousVersionId, - }) - if (reactivateResult.success) { - return - } - } - - await undeployWorkflow({ workflowId: id }) - } - - const deployResult = await deployWorkflow({ - workflowId: id, - deployedBy: actorUserId, - workflowName: workflowData!.name, - }) - - if (!deployResult.success) { - return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500) - } - - const deployedAt = deployResult.deployedAt! - const deploymentVersionId = deployResult.deploymentVersionId - - if (!deploymentVersionId) { - await undeployWorkflow({ workflowId: id }) - return createErrorResponse('Failed to resolve deployment version', 500) - } - - const triggerSaveResult = await saveTriggerWebhooksForDeploy({ - request, + const result = await performFullDeploy({ workflowId: id, - workflow: workflowData, userId: actorUserId, - blocks: normalizedData.blocks, + workflowName: workflowData!.name || undefined, requestId, - deploymentVersionId, - previousVersionId, + request, }) - if (!triggerSaveResult.success) { - await cleanupDeploymentVersion({ - workflowId: id, - workflow: workflowData as Record, - requestId, - deploymentVersionId, - }) - await rollbackDeployment() - return createErrorResponse( - triggerSaveResult.error?.message || 'Failed to save trigger configuration', - triggerSaveResult.error?.status || 500 - ) - } - - let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {} - const scheduleResult = await createSchedulesForDeploy( - id, - normalizedData.blocks, - db, - deploymentVersionId - ) - if (!scheduleResult.success) { - logger.error( - `[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}` - ) - await cleanupDeploymentVersion({ - workflowId: id, - workflow: workflowData as Record, - requestId, - deploymentVersionId, - }) - await rollbackDeployment() - return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500) - } - if (scheduleResult.scheduleId) { - scheduleInfo = { - scheduleId: scheduleResult.scheduleId, - cronExpression: scheduleResult.cronExpression, - nextRunAt: scheduleResult.nextRunAt, - } - logger.info( - `[${requestId}] Schedule created for workflow ${id}: ${scheduleResult.scheduleId}` - ) - } - - if (previousVersionId && previousVersionId !== deploymentVersionId) { - try { - logger.info(`[${requestId}] Cleaning up previous version ${previousVersionId} DB records`) - await cleanupDeploymentVersion({ - workflowId: id, - workflow: workflowData as Record, - requestId, - deploymentVersionId: previousVersionId, - skipExternalCleanup: true, - }) - } catch (cleanupError) { - logger.error( - `[${requestId}] Failed to clean up previous version ${previousVersionId}`, - cleanupError - ) - // Non-fatal - continue with success response - } + if (!result.success) { + const status = + result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 + return createErrorResponse(result.error || 'Failed to deploy workflow', status) } logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - // Sync MCP tools with the latest parameter schema - await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' }) - - recordAudit({ - workspaceId: workflowData?.workspaceId || null, - actorId: actorUserId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.WORKFLOW_DEPLOYED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: id, - resourceName: workflowData?.name, - description: `Deployed workflow "${workflowData?.name || id}"`, - metadata: { version: deploymentVersionId }, - request, - }) - const responseApiKeyInfo = workflowData!.workspaceId ? 'Workspace API keys' : 'Personal API keys' @@ -262,25 +103,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createSuccessResponse({ apiKey: responseApiKeyInfo, isDeployed: true, - deployedAt, - schedule: scheduleInfo.scheduleId - ? { - id: scheduleInfo.scheduleId, - cronExpression: scheduleInfo.cronExpression, - nextRunAt: scheduleInfo.nextRunAt, - } - : undefined, - warnings: triggerSaveResult.warnings, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error deploying workflow: ${id}`, { - error: error.message, - stack: error.stack, - name: error.name, - cause: error.cause, - fullError: error, + deployedAt: result.deployedAt, + warnings: result.warnings, }) - return createErrorResponse(error.message || 'Failed to deploy workflow', 500) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to deploy workflow' + logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error }) + return createErrorResponse(message, 500) } } @@ -328,60 +157,36 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } export async function DELETE( - request: NextRequest, + _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const requestId = generateRequestId() const { id } = await params try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') + const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin') if (error) { return createErrorResponse(error.message, error.status) } - const result = await undeployWorkflow({ workflowId: id }) + const result = await performFullUndeploy({ + workflowId: id, + userId: session!.user.id, + requestId, + }) + if (!result.success) { return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) } - await cleanupWebhooksForWorkflow(id, workflowData as Record, requestId) - - await removeMcpToolsForWorkflow(id, requestId) - - logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.workflowUndeployed({ workflowId: id }) - } catch (_e) { - // Silently fail - } - - recordAudit({ - workspaceId: workflowData?.workspaceId || null, - actorId: session!.user.id, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.WORKFLOW_UNDEPLOYED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: id, - resourceName: workflowData?.name, - description: `Undeployed workflow "${workflowData?.name || id}"`, - request, - }) - return createSuccessResponse({ isDeployed: false, deployedAt: null, apiKey: null, }) - } catch (error: any) { - logger.error(`[${requestId}] Error undeploying workflow: ${id}`, error) - return createErrorResponse(error.message || 'Failed to undeploy workflow', 500) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to undeploy workflow' + logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error }) + return createErrorResponse(message, 500) } } diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 56802840e95..7d4ab62d52c 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -3,19 +3,10 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' -import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy' -import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils' -import { - cleanupDeploymentVersion, - createSchedulesForDeploy, - validateWorkflowSchedules, -} from '@/lib/workflows/schedules' +import { performActivateVersion } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDeploymentVersionAPI') @@ -129,140 +120,25 @@ export async function PATCH( return createErrorResponse('Unable to determine activating user', 400) } - const [versionRow] = await db - .select({ - id: workflowDeploymentVersion.id, - state: workflowDeploymentVersion.state, - }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) - ) - ) - .limit(1) - - if (!versionRow?.state) { - return createErrorResponse('Deployment version not found', 404) - } - - const [currentActiveVersion] = await db - .select({ id: workflowDeploymentVersion.id }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - - const previousVersionId = currentActiveVersion?.id - - const deployedState = versionRow.state as { blocks?: Record } - const blocks = deployedState.blocks - if (!blocks || typeof blocks !== 'object') { - return createErrorResponse('Invalid deployed state structure', 500) - } - - const scheduleValidation = validateWorkflowSchedules(blocks) - if (!scheduleValidation.isValid) { - return createErrorResponse( - `Invalid schedule configuration: ${scheduleValidation.error}`, - 400 - ) - } - - const triggerSaveResult = await saveTriggerWebhooksForDeploy({ - request, + const activateResult = await performActivateVersion({ workflowId: id, - workflow: workflowData as Record, + version: versionNum, userId: actorUserId, - blocks, + workflow: workflowData as Record, requestId, - deploymentVersionId: versionRow.id, - previousVersionId, - forceRecreateSubscriptions: true, + request, }) - if (!triggerSaveResult.success) { - return createErrorResponse( - triggerSaveResult.error?.message || 'Failed to sync trigger configuration', - triggerSaveResult.error?.status || 500 - ) - } - - const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id) - - if (!scheduleResult.success) { - await cleanupDeploymentVersion({ - workflowId: id, - workflow: workflowData as Record, - requestId, - deploymentVersionId: versionRow.id, - }) - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData as Record, - userId: actorUserId, - previousVersionId, - requestId, - }) - } - return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500) + if (!activateResult.success) { + const status = + activateResult.errorCode === 'not_found' + ? 404 + : activateResult.errorCode === 'validation' + ? 400 + : 500 + return createErrorResponse(activateResult.error || 'Failed to activate deployment', status) } - const result = await activateWorkflowVersion({ workflowId: id, version: versionNum }) - if (!result.success) { - await cleanupDeploymentVersion({ - workflowId: id, - workflow: workflowData as Record, - requestId, - deploymentVersionId: versionRow.id, - }) - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData as Record, - userId: actorUserId, - previousVersionId, - requestId, - }) - } - return createErrorResponse(result.error || 'Failed to activate deployment', 400) - } - - if (previousVersionId && previousVersionId !== versionRow.id) { - try { - logger.info( - `[${requestId}] Cleaning up previous version ${previousVersionId} webhooks/schedules` - ) - await cleanupDeploymentVersion({ - workflowId: id, - workflow: workflowData as Record, - requestId, - deploymentVersionId: previousVersionId, - skipExternalCleanup: true, - }) - logger.info(`[${requestId}] Previous version cleanup completed`) - } catch (cleanupError) { - logger.error( - `[${requestId}] Failed to clean up previous version ${previousVersionId}`, - cleanupError - ) - } - } - - await syncMcpToolsForWorkflow({ - workflowId: id, - requestId, - state: versionRow.state, - context: 'activate', - }) - - // Apply name/description updates if provided alongside activation let updatedName: string | null | undefined let updatedDescription: string | null | undefined if (name !== undefined || description !== undefined) { @@ -298,23 +174,10 @@ export async function PATCH( } } - recordAudit({ - workspaceId: workflowData?.workspaceId, - actorId: actorUserId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: id, - description: `Activated deployment version ${versionNum}`, - metadata: { version: versionNum }, - request, - }) - return createSuccessResponse({ success: true, - deployedAt: result.deployedAt, - warnings: triggerSaveResult.warnings, + deployedAt: activateResult.deployedAt, + warnings: activateResult.warnings, ...(updatedName !== undefined && { name: updatedName }), ...(updatedDescription !== undefined && { description: updatedDescription }), }) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 2000e5093ee..383594b5453 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -5,14 +5,7 @@ * @vitest-environment node */ -import { - auditMock, - envMock, - loggerMock, - requestUtilsMock, - setupGlobalFetchMock, - telemetryMock, -} from '@sim/testing' +import { auditMock, envMock, loggerMock, requestUtilsMock, telemetryMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,7 +14,7 @@ const mockCheckSessionOrInternalAuth = vi.fn() const mockLoadWorkflowFromNormalizedTables = vi.fn() const mockGetWorkflowById = vi.fn() const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() -const mockArchiveWorkflow = vi.fn() +const mockPerformDeleteWorkflow = vi.fn() const mockDbUpdate = vi.fn() const mockDbSelect = vi.fn() @@ -72,8 +65,8 @@ vi.mock('@/lib/workflows/utils', () => ({ }) => mockAuthorizeWorkflowByWorkspacePermission(params), })) -vi.mock('@/lib/workflows/lifecycle', () => ({ - archiveWorkflow: (...args: unknown[]) => mockArchiveWorkflow(...args), +vi.mock('@/lib/workflows/orchestration', () => ({ + performDeleteWorkflow: (...args: unknown[]) => mockPerformDeleteWorkflow(...args), })) vi.mock('@sim/db', () => ({ @@ -294,18 +287,7 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'admin', }) - mockDbSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]), - }), - }) - - mockArchiveWorkflow.mockResolvedValue({ - archived: true, - workflow: mockWorkflow, - }) - - setupGlobalFetchMock({ ok: true }) + mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'DELETE', @@ -317,6 +299,12 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) + expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-123', + userId: 'user-123', + }) + ) }) it('should allow admin to delete workspace workflow', async () => { @@ -337,19 +325,7 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'admin', }) - // Mock db.select() to return multiple workflows so deletion is allowed - mockDbSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]), - }), - }) - - mockArchiveWorkflow.mockResolvedValue({ - archived: true, - workflow: mockWorkflow, - }) - - setupGlobalFetchMock({ ok: true }) + mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'DELETE', @@ -381,11 +357,10 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'admin', }) - // Mock db.select() to return only 1 workflow (the one being deleted) - mockDbSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), - }), + mockPerformDeleteWorkflow.mockResolvedValue({ + success: false, + error: 'Cannot delete the only workflow in the workspace', + errorCode: 'validation', }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 8b79fe2c287..c746d394db2 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,13 +1,12 @@ import { db } from '@sim/db' -import { templates, workflow } from '@sim/db/schema' +import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { archiveWorkflow } from '@/lib/workflows/lifecycle' +import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' @@ -184,28 +183,12 @@ export async function DELETE( ) } - // Check if this is the last workflow in the workspace - if (workflowData.workspaceId) { - const totalWorkflowsInWorkspace = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workflowData.workspaceId), isNull(workflow.archivedAt))) - - if (totalWorkflowsInWorkspace.length <= 1) { - return NextResponse.json( - { error: 'Cannot delete the only workflow in the workspace' }, - { status: 400 } - ) - } - } - - // Check if workflow has published templates before deletion const { searchParams } = new URL(request.url) const checkTemplates = searchParams.get('check-templates') === 'true' const deleteTemplatesParam = searchParams.get('deleteTemplates') if (checkTemplates) { - // Return template information for frontend to handle + const { templates } = await import('@sim/db/schema') const publishedTemplates = await db .select({ id: templates.id, @@ -229,49 +212,22 @@ export async function DELETE( }) } - // Handle template deletion based on user choice - if (deleteTemplatesParam !== null) { - const deleteTemplates = deleteTemplatesParam === 'delete' - - if (deleteTemplates) { - // Delete all templates associated with this workflow - await db.delete(templates).where(eq(templates.workflowId, workflowId)) - logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`) - } else { - // Orphan the templates (set workflowId to null) - await db - .update(templates) - .set({ workflowId: null }) - .where(eq(templates.workflowId, workflowId)) - logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`) - } - } + const result = await performDeleteWorkflow({ + workflowId, + userId, + requestId, + templateAction: deleteTemplatesParam === 'delete' ? 'delete' : 'orphan', + }) - const archiveResult = await archiveWorkflow(workflowId, { requestId }) - if (!archiveResult.workflow) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) } const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) - recordAudit({ - workspaceId: workflowData.workspaceId || null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_DELETED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: workflowData.name, - description: `Archived workflow "${workflowData.name}"`, - metadata: { - archived: archiveResult.archived, - deleteTemplates: deleteTemplatesParam === 'delete', - }, - request, - }) - return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime diff --git a/apps/sim/lib/audit/log.ts b/apps/sim/lib/audit/log.ts index 6a45f49664f..f37e27b382a 100644 --- a/apps/sim/lib/audit/log.ts +++ b/apps/sim/lib/audit/log.ts @@ -26,6 +26,11 @@ export const AuditAction = { CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + // Custom Tools + CUSTOM_TOOL_CREATED: 'custom_tool.created', + CUSTOM_TOOL_UPDATED: 'custom_tool.updated', + CUSTOM_TOOL_DELETED: 'custom_tool.deleted', + // Billing CREDIT_PURCHASED: 'credit.purchased', @@ -99,8 +104,10 @@ export const AuditAction = { NOTIFICATION_UPDATED: 'notification.updated', NOTIFICATION_DELETED: 'notification.deleted', - // OAuth + // OAuth / Credentials OAUTH_DISCONNECTED: 'oauth.disconnected', + CREDENTIAL_RENAMED: 'credential.renamed', + CREDENTIAL_DELETED: 'credential.deleted', // Password PASSWORD_RESET: 'password.reset', @@ -124,6 +131,11 @@ export const AuditAction = { PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', + // Skills + SKILL_CREATED: 'skill.created', + SKILL_UPDATED: 'skill.updated', + SKILL_DELETED: 'skill.deleted', + // Schedules SCHEDULE_UPDATED: 'schedule.updated', @@ -173,6 +185,7 @@ export const AuditResourceType = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL_SET: 'credential_set', + CUSTOM_TOOL: 'custom_tool', DOCUMENT: 'document', ENVIRONMENT: 'environment', FILE: 'file', @@ -186,6 +199,7 @@ export const AuditResourceType = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', + SKILL: 'skill', TABLE: 'table', TEMPLATE: 'template', WEBHOOK: 'webhook', diff --git a/apps/sim/lib/copilot/client-sse/content-blocks.ts b/apps/sim/lib/copilot/client-sse/content-blocks.ts deleted file mode 100644 index e7ec0b726a2..00000000000 --- a/apps/sim/lib/copilot/client-sse/content-blocks.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { ClientContentBlock, ClientStreamingContext } from '@/lib/copilot/client-sse/types' - -/** - * Appends plain text to the active text block, or starts a new one when needed. - */ -export function appendTextBlock(context: ClientStreamingContext, content: string): void { - if (!content) return - - context.accumulatedContent += content - - if (context.currentTextBlock?.type === 'text') { - context.currentTextBlock.content = `${context.currentTextBlock.content || ''}${content}` - return - } - - const block: ClientContentBlock = { - type: 'text', - content, - timestamp: Date.now(), - } - - context.currentTextBlock = block - context.contentBlocks.push(block) -} - -/** - * Starts a new thinking block when the stream enters a reasoning segment. - */ -export function beginThinkingBlock(context: ClientStreamingContext): void { - if (context.currentThinkingBlock) { - context.isInThinkingBlock = true - context.currentTextBlock = null - return - } - - const block: ClientContentBlock = { - type: 'thinking', - content: '', - timestamp: Date.now(), - startTime: Date.now(), - } - - context.currentThinkingBlock = block - context.contentBlocks.push(block) - context.currentTextBlock = null - context.isInThinkingBlock = true -} - -/** - * Closes the active thinking block and records its visible duration. - */ -export function finalizeThinkingBlock(context: ClientStreamingContext): void { - if (!context.currentThinkingBlock) { - context.isInThinkingBlock = false - return - } - - const startTime = context.currentThinkingBlock.startTime ?? context.currentThinkingBlock.timestamp - context.currentThinkingBlock.duration = Math.max(0, Date.now() - startTime) - context.currentThinkingBlock = null - context.isInThinkingBlock = false -} diff --git a/apps/sim/lib/copilot/client-sse/handlers.ts b/apps/sim/lib/copilot/client-sse/handlers.ts deleted file mode 100644 index e812311d5f3..00000000000 --- a/apps/sim/lib/copilot/client-sse/handlers.ts +++ /dev/null @@ -1,1115 +0,0 @@ -import { createLogger } from '@sim/logger' -import { - appendTextBlock, - beginThinkingBlock, - finalizeThinkingBlock, -} from '@/lib/copilot/client-sse/content-blocks' -import { STREAM_STORAGE_KEY } from '@/lib/copilot/constants' -import { asRecord } from '@/lib/copilot/orchestrator/sse/utils' -import type { SSEEvent } from '@/lib/copilot/orchestrator/types' -import { - isBackgroundState, - isRejectedState, - isReviewState, - resolveToolDisplay, -} from '@/lib/copilot/store-utils' -import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' -import type { CopilotStore, CopilotStreamInfo, CopilotToolCall } from '@/stores/panel/copilot/types' -import { useVariablesStore } from '@/stores/panel/variables/store' -import { useEnvironmentStore } from '@/stores/settings/environment/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowState } from '@/stores/workflows/workflow/types' -import type { ClientContentBlock, ClientStreamingContext } from './types' - -const logger = createLogger('CopilotClientSseHandlers') -const TEXT_BLOCK_TYPE = 'text' - -const MAX_BATCH_INTERVAL = 50 -const MIN_BATCH_INTERVAL = 16 -const MAX_QUEUE_SIZE = 5 - -interface StreamMessage extends Record { - id: string - requestId?: string - content?: string - contentBlocks?: ClientContentBlock[] - error?: string -} - -interface ChatSummary extends Record { - id: string - title?: string -} - -function isStreamMessage(value: Record): value is StreamMessage { - return typeof value.id === 'string' -} - -function getStreamMessages(store: CopilotStore): StreamMessage[] { - return store.messages.filter(isStreamMessage) -} - -function getChatState(store: CopilotStore): { - currentChat?: ChatSummary - chats: ChatSummary[] -} { - const currentChat = asRecord(store.currentChat) - const rawChats = Array.isArray(store.chats) ? store.chats : [] - - return { - currentChat: - typeof currentChat.id === 'string' - ? { ...currentChat, id: currentChat.id, title: currentChat.title as string | undefined } - : undefined, - chats: rawChats - .map((chat) => asRecord(chat)) - .filter( - (chat): chat is Record & { id: string } => typeof chat.id === 'string' - ) - .map((chat) => ({ ...chat, id: chat.id, title: chat.title as string | undefined })), - } -} - -function abortInProgressTools(set: StoreSet, get: () => CopilotStore): void { - const { toolCallsById } = get() - const updatedToolCalls = Object.fromEntries( - Object.entries(toolCallsById).map(([toolCallId, toolCall]) => { - if ( - toolCall.state === ClientToolCallState.executing || - toolCall.state === ClientToolCallState.generating || - toolCall.state === ClientToolCallState.pending - ) { - const state = ClientToolCallState.aborted - return [ - toolCallId, - { - ...toolCall, - state, - display: resolveToolDisplay( - toolCall.name, - state, - toolCall.id, - toolCall.params, - toolCall.serverUI - ), - }, - ] - } - - return [toolCallId, toolCall] - }) - ) - - set({ toolCallsById: updatedToolCalls }) -} - -function writeActiveStreamToStorage(info: CopilotStreamInfo | null): void { - if (typeof window === 'undefined') return - try { - if (!info) { - window.sessionStorage.removeItem(STREAM_STORAGE_KEY) - return - } - window.sessionStorage.setItem(STREAM_STORAGE_KEY, JSON.stringify(info)) - } catch (error) { - logger.warn('Failed to write active stream to storage', { - error: error instanceof Error ? error.message : String(error), - }) - } -} - -type StoreSet = ( - partial: Partial | ((state: CopilotStore) => Partial) -) => void - -export type SSEHandler = ( - data: SSEEvent, - context: ClientStreamingContext, - get: () => CopilotStore, - set: StoreSet -) => Promise | void - -const streamingUpdateQueue = new Map() -let streamingUpdateRAF: number | null = null -let lastBatchTime = 0 - -export function stopStreamingUpdates() { - if (streamingUpdateRAF !== null) { - cancelAnimationFrame(streamingUpdateRAF) - streamingUpdateRAF = null - } - streamingUpdateQueue.clear() -} - -function createOptimizedContentBlocks(contentBlocks: ClientContentBlock[]): ClientContentBlock[] { - const result: ClientContentBlock[] = new Array(contentBlocks.length) - for (let i = 0; i < contentBlocks.length; i++) { - const block = contentBlocks[i] - result[i] = { ...block } - } - return result -} - -export function flushStreamingUpdates(set: StoreSet) { - if (streamingUpdateRAF !== null) { - cancelAnimationFrame(streamingUpdateRAF) - streamingUpdateRAF = null - } - if (streamingUpdateQueue.size === 0) return - - const updates = new Map(streamingUpdateQueue) - streamingUpdateQueue.clear() - - set((state: CopilotStore) => { - if (updates.size === 0) return state - return { - messages: getStreamMessages(state).map((msg) => { - const update = updates.get(msg.id) - if (update) { - return { - ...msg, - requestId: update.requestId ?? msg.requestId, - content: '', - contentBlocks: - update.contentBlocks.length > 0 - ? createOptimizedContentBlocks(update.contentBlocks) - : [], - } - } - return msg - }), - } - }) -} - -export function updateStreamingMessage(set: StoreSet, context: ClientStreamingContext) { - if (context.suppressStreamingUpdates) return - const now = performance.now() - streamingUpdateQueue.set(context.messageId, context) - const timeSinceLastBatch = now - lastBatchTime - const shouldFlushImmediately = - streamingUpdateQueue.size >= MAX_QUEUE_SIZE || timeSinceLastBatch > MAX_BATCH_INTERVAL - - if (streamingUpdateRAF === null) { - const scheduleUpdate = () => { - streamingUpdateRAF = requestAnimationFrame(() => { - const updates = new Map(streamingUpdateQueue) - streamingUpdateQueue.clear() - streamingUpdateRAF = null - lastBatchTime = performance.now() - set((state: CopilotStore) => { - if (updates.size === 0) return state - const messages = getStreamMessages(state) - const lastMessage = messages[messages.length - 1] - const lastMessageUpdate = lastMessage ? updates.get(lastMessage.id) : null - if (updates.size === 1 && lastMessageUpdate) { - const newMessages = [...messages] - newMessages[messages.length - 1] = { - ...lastMessage, - requestId: lastMessageUpdate.requestId ?? lastMessage.requestId, - content: '', - contentBlocks: - lastMessageUpdate.contentBlocks.length > 0 - ? createOptimizedContentBlocks(lastMessageUpdate.contentBlocks) - : [], - } - return { messages: newMessages } - } - return { - messages: messages.map((msg) => { - const update = updates.get(msg.id) - if (update) { - return { - ...msg, - requestId: update.requestId ?? msg.requestId, - content: '', - contentBlocks: - update.contentBlocks.length > 0 - ? createOptimizedContentBlocks(update.contentBlocks) - : [], - } - } - return msg - }), - } - }) - }) - } - if (shouldFlushImmediately) scheduleUpdate() - else setTimeout(scheduleUpdate, Math.max(0, MIN_BATCH_INTERVAL - timeSinceLastBatch)) - } -} - -export function upsertToolCallBlock(context: ClientStreamingContext, toolCall: CopilotToolCall) { - let found = false - for (let i = 0; i < context.contentBlocks.length; i++) { - const b = context.contentBlocks[i] - if (b.type === 'tool_call' && b.toolCall?.id === toolCall.id) { - context.contentBlocks[i] = { ...b, toolCall } - found = true - break - } - } - if (!found) { - context.contentBlocks.push({ type: 'tool_call', toolCall, timestamp: Date.now() }) - } -} - -function stripThinkingTags(text: string): string { - return text.replace(/<\/?thinking[^>]*>/gi, '').replace(/<\/?thinking[^&]*>/gi, '') -} - -function appendThinkingContent(context: ClientStreamingContext, text: string) { - if (!text) return - const cleanedText = stripThinkingTags(text) - if (!cleanedText) return - if (context.currentThinkingBlock) { - context.currentThinkingBlock.content += cleanedText - } else { - const newBlock: ClientContentBlock = { - type: 'thinking', - content: cleanedText, - timestamp: Date.now(), - startTime: Date.now(), - } - context.currentThinkingBlock = newBlock - context.contentBlocks.push(newBlock) - } - context.isInThinkingBlock = true - context.currentTextBlock = null -} - -function processContentBuffer( - context: ClientStreamingContext, - get: () => CopilotStore, - set: StoreSet -) { - let contentToProcess = context.pendingContent - let hasProcessedContent = false - - const thinkingStartRegex = // - const thinkingEndRegex = /<\/thinking>/ - const designWorkflowStartRegex = // - const designWorkflowEndRegex = /<\/design_workflow>/ - - const splitTrailingPartialTag = ( - text: string, - tags: string[] - ): { text: string; remaining: string } => { - const partialIndex = text.lastIndexOf('<') - if (partialIndex < 0) { - return { text, remaining: '' } - } - const possibleTag = text.substring(partialIndex) - const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag)) - if (!matchesTagStart) { - return { text, remaining: '' } - } - return { - text: text.substring(0, partialIndex), - remaining: possibleTag, - } - } - - while (contentToProcess.length > 0) { - if (context.isInDesignWorkflowBlock) { - const endMatch = designWorkflowEndRegex.exec(contentToProcess) - if (endMatch) { - const designContent = contentToProcess.substring(0, endMatch.index) - context.designWorkflowContent += designContent - context.isInDesignWorkflowBlock = false - - logger.info('[design_workflow] Tag complete, setting plan content', { - contentLength: context.designWorkflowContent.length, - }) - set({ streamingPlanContent: context.designWorkflowContent }) - - contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) - hasProcessedContent = true - } else { - const { text, remaining } = splitTrailingPartialTag(contentToProcess, [ - '', - ]) - context.designWorkflowContent += text - - set({ streamingPlanContent: context.designWorkflowContent }) - - contentToProcess = remaining - hasProcessedContent = true - if (remaining) { - break - } - } - continue - } - - if (!context.isInThinkingBlock && !context.isInDesignWorkflowBlock) { - const designStartMatch = designWorkflowStartRegex.exec(contentToProcess) - if (designStartMatch) { - const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index) - if (textBeforeDesign) { - appendTextBlock(context, textBeforeDesign) - hasProcessedContent = true - } - context.isInDesignWorkflowBlock = true - context.designWorkflowContent = '' - contentToProcess = contentToProcess.substring( - designStartMatch.index + designStartMatch[0].length - ) - hasProcessedContent = true - continue - } - - const nextMarkIndex = contentToProcess.indexOf('') - const nextCheckIndex = contentToProcess.indexOf('') - const hasMark = nextMarkIndex >= 0 - const hasCheck = nextCheckIndex >= 0 - - const nextTagIndex = - hasMark && hasCheck - ? Math.min(nextMarkIndex, nextCheckIndex) - : hasMark - ? nextMarkIndex - : hasCheck - ? nextCheckIndex - : -1 - - if (nextTagIndex >= 0) { - const isMarkTodo = hasMark && nextMarkIndex === nextTagIndex - const tagStart = isMarkTodo ? '' : '' - const tagEnd = isMarkTodo ? '' : '' - const closingIndex = contentToProcess.indexOf(tagEnd, nextTagIndex + tagStart.length) - - if (closingIndex === -1) { - break - } - - const todoId = contentToProcess - .substring(nextTagIndex + tagStart.length, closingIndex) - .trim() - logger.info( - isMarkTodo ? '[TODO] Detected marktodo tag' : '[TODO] Detected checkofftodo tag', - { todoId } - ) - - if (todoId) { - try { - get().updatePlanTodoStatus(todoId, isMarkTodo ? 'executing' : 'completed') - logger.info( - isMarkTodo - ? '[TODO] Successfully marked todo in progress' - : '[TODO] Successfully checked off todo', - { todoId } - ) - } catch (e) { - logger.error( - isMarkTodo - ? '[TODO] Failed to mark todo in progress' - : '[TODO] Failed to checkoff todo', - { todoId, error: e } - ) - } - } else { - logger.warn('[TODO] Empty todoId extracted from todo tag', { tagType: tagStart }) - } - - let beforeTag = contentToProcess.substring(0, nextTagIndex) - let afterTag = contentToProcess.substring(closingIndex + tagEnd.length) - - const hadNewlineBefore = /(\r?\n)+$/.test(beforeTag) - const hadNewlineAfter = /^(\r?\n)+/.test(afterTag) - - beforeTag = beforeTag.replace(/(\r?\n)+$/, '') - afterTag = afterTag.replace(/^(\r?\n)+/, '') - - contentToProcess = beforeTag + (hadNewlineBefore && hadNewlineAfter ? '\n' : '') + afterTag - context.currentTextBlock = null - hasProcessedContent = true - continue - } - } - - if (context.isInThinkingBlock) { - const endMatch = thinkingEndRegex.exec(contentToProcess) - if (endMatch) { - const thinkingContent = contentToProcess.substring(0, endMatch.index) - appendThinkingContent(context, thinkingContent) - finalizeThinkingBlock(context) - contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) - hasProcessedContent = true - } else { - const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) - if (text) { - appendThinkingContent(context, text) - hasProcessedContent = true - } - contentToProcess = remaining - if (remaining) { - break - } - } - } else { - const startMatch = thinkingStartRegex.exec(contentToProcess) - if (startMatch) { - const textBeforeThinking = contentToProcess.substring(0, startMatch.index) - if (textBeforeThinking) { - appendTextBlock(context, textBeforeThinking) - hasProcessedContent = true - } - context.isInThinkingBlock = true - context.currentTextBlock = null - contentToProcess = contentToProcess.substring(startMatch.index + startMatch[0].length) - hasProcessedContent = true - } else { - let partialTagIndex = contentToProcess.lastIndexOf('<') - - const partialMarkTodo = contentToProcess.lastIndexOf(' partialTagIndex) { - partialTagIndex = partialMarkTodo - } - if (partialCheckoffTodo > partialTagIndex) { - partialTagIndex = partialCheckoffTodo - } - - let textToAdd = contentToProcess - let remaining = '' - if (partialTagIndex >= 0 && partialTagIndex > contentToProcess.length - 50) { - textToAdd = contentToProcess.substring(0, partialTagIndex) - remaining = contentToProcess.substring(partialTagIndex) - } - if (textToAdd) { - appendTextBlock(context, textToAdd) - hasProcessedContent = true - } - contentToProcess = remaining - break - } - } - } - - context.pendingContent = contentToProcess - if (hasProcessedContent) { - updateStreamingMessage(set, context) - } -} - -export const sseHandlers: Record = { - chat_id: async (data, context, get, set) => { - context.newChatId = data.chatId - const { currentChat, activeStream } = get() - if (!currentChat && context.newChatId) { - await get().handleNewChatCreation(context.newChatId) - } - if (activeStream && context.newChatId && !activeStream.chatId) { - const updatedStream = { ...activeStream, chatId: context.newChatId } - set({ activeStream: updatedStream }) - writeActiveStreamToStorage(updatedStream) - } - }, - request_id: (data, context) => { - const requestId = typeof data.data === 'string' ? data.data : undefined - if (requestId) { - context.requestId = requestId - } - }, - title_updated: (_data, _context, get, set) => { - const title = _data.title - if (!title) return - const { currentChat, chats } = getChatState(get()) - if (currentChat) { - set({ - currentChat: { ...currentChat, title }, - chats: chats.map((c) => (c.id === currentChat.id ? { ...c, title } : c)), - }) - } - }, - tool_result: (data, context, get, set) => { - try { - const eventData = asRecord(data?.data) - const toolCallId: string | undefined = - data?.toolCallId || (eventData.id as string | undefined) - if (!toolCallId) return - const { toolCallsById } = get() - const current = toolCallsById[toolCallId] - if (current) { - if ( - isRejectedState(current.state) || - isReviewState(current.state) || - isBackgroundState(current.state) - ) { - return - } - const targetState = - (data?.state as ClientToolCallState) || - (data?.success ? ClientToolCallState.success : ClientToolCallState.error) - const updatedMap = { ...toolCallsById } - updatedMap[toolCallId] = { - ...current, - state: targetState, - result: { - success: !!data?.success, - output: data?.result ?? undefined, - error: (data?.error as string) ?? undefined, - }, - display: resolveToolDisplay( - current.name, - targetState, - current.id, - current.params, - current.serverUI - ), - } - set({ toolCallsById: updatedMap }) - - if (targetState === ClientToolCallState.success && current.name === 'checkoff_todo') { - try { - const result = asRecord(data?.result) || asRecord(eventData.result) - const input = asRecord(current.params || current.input) - const todoId = (input.id || input.todoId || result.id || result.todoId) as - | string - | undefined - if (todoId) { - get().updatePlanTodoStatus(todoId, 'completed') - } - } catch (error) { - logger.warn('Failed to process checkoff_todo tool result', { - error: error instanceof Error ? error.message : String(error), - toolCallId, - }) - } - } - - if ( - targetState === ClientToolCallState.success && - current.name === 'mark_todo_in_progress' - ) { - try { - const result = asRecord(data?.result) || asRecord(eventData.result) - const input = asRecord(current.params || current.input) - const todoId = (input.id || input.todoId || result.id || result.todoId) as - | string - | undefined - if (todoId) { - get().updatePlanTodoStatus(todoId, 'executing') - } - } catch (error) { - logger.warn('Failed to process mark_todo_in_progress tool result', { - error: error instanceof Error ? error.message : String(error), - toolCallId, - }) - } - } - - if (current.name === 'edit_workflow') { - try { - const resultPayload = asRecord( - data?.result || eventData.result || eventData.data || data?.data - ) - const input = asRecord(current.params || current.input) - const workflowId = - (input?.workflowId as string) || useWorkflowRegistry.getState().activeWorkflowId - - if (!workflowId) { - logger.warn('[SSE] edit_workflow result has no workflowId, skipping diff') - } else { - const baselineWorkflow = captureBaselineSnapshot(workflowId) - - // Re-fetch the state the server just wrote to DB. - // Never use the response's workflowState directly — that would - // mean client and server independently track state, creating - // race conditions when the build agent makes sequential calls. - logger.info('[SSE] edit_workflow success, fetching state from DB', { workflowId }) - fetch(`/api/workflows/${workflowId}/state`) - .then((res) => { - if (!res.ok) throw new Error(`State fetch failed: ${res.status}`) - return res.json() - }) - .then((freshState) => { - const diffStore = useWorkflowDiffStore.getState() - return diffStore.setProposedChanges(freshState as WorkflowState, undefined, { - baselineWorkflow, - skipPersist: true, - }) - }) - .catch((err) => { - logger.error('[SSE] Failed to fetch/apply edit_workflow state', { - error: err instanceof Error ? err.message : String(err), - workflowId, - }) - // Fallback: use the response's workflowState if DB fetch failed - if (resultPayload?.workflowState) { - const diffStore = useWorkflowDiffStore.getState() - diffStore - .setProposedChanges(resultPayload.workflowState as WorkflowState, undefined, { - baselineWorkflow, - skipPersist: true, - }) - .catch(() => {}) - } - }) - } - } catch (err) { - logger.error('[SSE] edit_workflow result handling failed', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - - if ( - targetState === ClientToolCallState.success && - (current.name === 'deploy_api' || - current.name === 'deploy_chat' || - current.name === 'deploy_mcp' || - current.name === 'redeploy') - ) { - try { - const resultPayload = asRecord( - data?.result || eventData.result || eventData.data || data?.data - ) - if (typeof resultPayload?.isDeployed === 'boolean') { - const input = asRecord(current.params) - const workflowId = - (resultPayload?.workflowId as string) || - (input?.workflowId as string) || - useWorkflowRegistry.getState().activeWorkflowId - const isDeployed = resultPayload.isDeployed as boolean - const serverDeployedAt = resultPayload.deployedAt - ? new Date(resultPayload.deployedAt as string) - : undefined - if (workflowId) { - useWorkflowRegistry - .getState() - .setDeploymentStatus( - workflowId, - isDeployed, - isDeployed ? (serverDeployedAt ?? new Date()) : undefined - ) - logger.info('[SSE] Updated deployment status from tool result', { - toolName: current.name, - workflowId, - isDeployed, - }) - } - } - } catch (err) { - logger.warn('[SSE] Failed to hydrate deployment status', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - - // Environment variables: reload store after successful set - if ( - targetState === ClientToolCallState.success && - current.name === 'set_environment_variables' - ) { - try { - useEnvironmentStore.getState().loadEnvironmentVariables() - logger.info('[SSE] Triggered environment variables reload') - } catch (err) { - logger.warn('[SSE] Failed to reload environment variables', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - - // Workflow variables: reload store after successful set - if ( - targetState === ClientToolCallState.success && - current.name === 'set_global_workflow_variables' - ) { - try { - const input = asRecord(current.params) - const workflowId = - (input?.workflowId as string) || useWorkflowRegistry.getState().activeWorkflowId - if (workflowId) { - useVariablesStore.getState().loadForWorkflow(workflowId) - logger.info('[SSE] Triggered workflow variables reload', { workflowId }) - } - } catch (err) { - logger.warn('[SSE] Failed to reload workflow variables', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - - // Generate API key: update deployment status with the new key - if (targetState === ClientToolCallState.success && current.name === 'generate_api_key') { - try { - const resultPayload = asRecord( - data?.result || eventData.result || eventData.data || data?.data - ) - const input = asRecord(current.params) - const workflowId = - (input?.workflowId as string) || useWorkflowRegistry.getState().activeWorkflowId - const apiKey = (resultPayload?.apiKey || resultPayload?.key) as string | undefined - if (workflowId) { - const existingStatus = useWorkflowRegistry - .getState() - .getWorkflowDeploymentStatus(workflowId) - useWorkflowRegistry - .getState() - .setDeploymentStatus( - workflowId, - existingStatus?.isDeployed ?? false, - existingStatus?.deployedAt, - apiKey - ) - logger.info('[SSE] Updated deployment status with API key', { - workflowId, - hasKey: !!apiKey, - }) - } - } catch (err) { - logger.warn('[SSE] Failed to hydrate API key status', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - } - - const blockState = - (data?.state as ClientToolCallState) || - (data?.success ? ClientToolCallState.success : ClientToolCallState.error) - for (let i = 0; i < context.contentBlocks.length; i++) { - const b = context.contentBlocks[i] - if (b?.type === 'tool_call' && b?.toolCall?.id === toolCallId) { - if ( - isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) - ) - break - context.contentBlocks[i] = { - ...b, - toolCall: { - ...b.toolCall, - state: blockState, - display: resolveToolDisplay( - b.toolCall?.name, - blockState, - toolCallId, - b.toolCall?.params, - b.toolCall?.serverUI - ), - }, - } - break - } - } - updateStreamingMessage(set, context) - } catch (error) { - logger.warn('Failed to process tool_result SSE event', { - error: error instanceof Error ? error.message : String(error), - }) - } - }, - tool_error: (data, context, get, set) => { - try { - const errorData = asRecord(data?.data) - const toolCallId: string | undefined = - data?.toolCallId || (errorData.id as string | undefined) - if (!toolCallId) return - const targetState = (data?.state as ClientToolCallState) || ClientToolCallState.error - const { toolCallsById } = get() - const current = toolCallsById[toolCallId] - if (current) { - if ( - isRejectedState(current.state) || - isReviewState(current.state) || - isBackgroundState(current.state) - ) { - return - } - const updatedMap = { ...toolCallsById } - updatedMap[toolCallId] = { - ...current, - state: targetState, - display: resolveToolDisplay( - current.name, - targetState, - current.id, - current.params, - current.serverUI - ), - } - set({ toolCallsById: updatedMap }) - } - for (let i = 0; i < context.contentBlocks.length; i++) { - const b = context.contentBlocks[i] - if (b?.type === 'tool_call' && b?.toolCall?.id === toolCallId) { - if ( - isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) - ) - break - context.contentBlocks[i] = { - ...b, - toolCall: { - ...b.toolCall, - state: targetState, - display: resolveToolDisplay( - b.toolCall?.name, - targetState, - toolCallId, - b.toolCall?.params, - b.toolCall?.serverUI - ), - }, - } - break - } - } - updateStreamingMessage(set, context) - } catch (error) { - logger.warn('Failed to process tool_error SSE event', { - error: error instanceof Error ? error.message : String(error), - }) - } - }, - tool_call_delta: (data, context, get, set) => { - const toolCallId = data?.toolCallId - if (!toolCallId) return - - const delta = typeof data?.data === 'string' ? data.data : '' - if (!delta) return - - const { toolCallsById } = get() - const existing = toolCallsById[toolCallId] - if (!existing) return - - const updated: CopilotToolCall = { - ...existing, - streamingArgs: (existing.streamingArgs ?? '') + delta, - } - set({ toolCallsById: { ...toolCallsById, [toolCallId]: updated } }) - upsertToolCallBlock(context, updated) - updateStreamingMessage(set, context) - }, - tool_generating: (data, context, get, set) => { - const { toolCallId, toolName } = data - if (!toolCallId || !toolName) return - const { toolCallsById } = get() - - if (!toolCallsById[toolCallId]) { - const tc: CopilotToolCall = { - id: toolCallId, - name: toolName, - state: ClientToolCallState.generating, - display: resolveToolDisplay(toolName, ClientToolCallState.generating, toolCallId), - } - const updated = { ...toolCallsById, [toolCallId]: tc } - set({ toolCallsById: updated }) - logger.info('[toolCallsById] map updated', updated) - - upsertToolCallBlock(context, tc) - updateStreamingMessage(set, context) - } - }, - tool_call: (data, context, get, set) => { - const toolData = asRecord(data?.data) - const id: string | undefined = (toolData.id as string | undefined) || data?.toolCallId - const name: string | undefined = (toolData.name as string | undefined) || data?.toolName - if (!id) return - const args = toolData.arguments as Record | undefined - const isPartial = toolData.partial === true - const { toolCallsById } = get() - - const rawUI = (toolData.ui || data?.ui) as Record | undefined - const serverUI = rawUI - ? { - title: rawUI.title as string | undefined, - phaseLabel: rawUI.phaseLabel as string | undefined, - icon: rawUI.icon as string | undefined, - } - : undefined - - const existing = toolCallsById[id] - const toolName = name || existing?.name || 'unknown_tool' - - const clientExecutable = rawUI?.clientExecutable === true - - let initialState: ClientToolCallState - if (isPartial) { - initialState = existing?.state || ClientToolCallState.generating - } else { - initialState = (data?.state as ClientToolCallState) || ClientToolCallState.executing - } - - if ( - existing?.state === ClientToolCallState.executing && - initialState === ClientToolCallState.pending - ) { - initialState = ClientToolCallState.executing - } - - const effectiveServerUI = serverUI || existing?.serverUI - - const next: CopilotToolCall = existing - ? { - ...existing, - name: toolName, - state: initialState, - ...(args ? { params: args } : {}), - ...(effectiveServerUI ? { serverUI: effectiveServerUI } : {}), - ...(clientExecutable ? { clientExecutable: true } : {}), - ...(!isPartial ? { streamingArgs: undefined } : {}), - display: resolveToolDisplay( - toolName, - initialState, - id, - args || existing.params, - effectiveServerUI - ), - } - : { - id, - name: toolName, - state: initialState, - ...(args ? { params: args } : {}), - ...(serverUI ? { serverUI } : {}), - ...(clientExecutable ? { clientExecutable: true } : {}), - display: resolveToolDisplay(toolName, initialState, id, args, serverUI), - } - const updated = { ...toolCallsById, [id]: next } - set({ toolCallsById: updated }) - logger.info(`[toolCallsById] → ${initialState}`, { id, name: toolName, params: args }) - - upsertToolCallBlock(context, next) - updateStreamingMessage(set, context) - - if (isPartial) { - return - } - - if (toolName === 'oauth_request_access' && args && typeof window !== 'undefined') { - try { - window.dispatchEvent( - new CustomEvent('open-oauth-connect', { - detail: { - providerName: (args.providerName || args.provider_name || '') as string, - serviceId: (args.serviceId || args.service_id || '') as string, - providerId: (args.providerId || args.provider_id || '') as string, - requiredScopes: (args.requiredScopes || args.required_scopes || []) as string[], - newScopes: (args.newScopes || args.new_scopes || []) as string[], - }, - }) - ) - logger.info('[SSE] Dispatched OAuth connect event', { - providerId: args.providerId || args.provider_id, - providerName: args.providerName || args.provider_name, - }) - } catch (err) { - logger.warn('[SSE] Failed to dispatch OAuth connect event', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - - return - }, - reasoning: (data, context, _get, set) => { - const phase = (data && (data.phase || data?.data?.phase)) as string | undefined - if (phase === 'start') { - beginThinkingBlock(context) - updateStreamingMessage(set, context) - return - } - if (phase === 'end') { - finalizeThinkingBlock(context) - updateStreamingMessage(set, context) - return - } - const chunk: string = typeof data?.data === 'string' ? data.data : data?.content || '' - if (!chunk) return - appendThinkingContent(context, chunk) - updateStreamingMessage(set, context) - }, - context_compaction_start: (_data, context, get, set) => { - const id = `compaction_${Date.now()}` - context.activeCompactionId = id - const toolName = 'context_compaction' - const state = ClientToolCallState.executing - const tc: CopilotToolCall = { - id, - name: toolName, - state, - params: {}, - display: resolveToolDisplay(toolName, state, id, {}), - } - const { toolCallsById } = get() - set({ toolCallsById: { ...toolCallsById, [id]: tc } }) - upsertToolCallBlock(context, tc) - updateStreamingMessage(set, context) - }, - context_compaction: (data, context, get, set) => { - const eventData = asRecord(data?.data) - const summaryChars = (eventData.summary_chars as number) || 0 - const id = context.activeCompactionId || `compaction_${Date.now()}` - context.activeCompactionId = undefined - const toolName = 'context_compaction' - const state = ClientToolCallState.success - const params = { summary_chars: summaryChars } - const tc: CopilotToolCall = { - id, - name: toolName, - state, - params, - display: resolveToolDisplay(toolName, state, id, params), - } - const { toolCallsById } = get() - set({ toolCallsById: { ...toolCallsById, [id]: tc } }) - upsertToolCallBlock(context, tc) - updateStreamingMessage(set, context) - }, - content: (data, context, get, set) => { - if (typeof data.data !== 'string') return - context.pendingContent += data.data - processContentBuffer(context, get, set) - }, - done: (_data, context) => { - logger.info('[SSE] DONE EVENT RECEIVED', { - doneEventCount: context.doneEventCount, - data: _data, - }) - context.doneEventCount++ - if (context.doneEventCount >= 1) { - logger.info('[SSE] Setting streamComplete = true, stream will terminate') - context.streamComplete = true - } - }, - error: (data, context, _get, set) => { - logger.error('Stream error:', data.error) - set((state: CopilotStore) => ({ - messages: getStreamMessages(state).map((msg) => - msg.id === context.messageId - ? { - ...msg, - content: context.accumulatedContent || 'An error occurred.', - error: data.error, - } - : msg - ), - })) - context.streamComplete = true - }, - stream_end: (_data, context, get, set) => { - if (context.pendingContent) { - if (context.isInThinkingBlock && context.currentThinkingBlock) { - appendThinkingContent(context, context.pendingContent) - } else if (context.pendingContent.trim()) { - appendTextBlock(context, context.pendingContent) - } - context.pendingContent = '' - } - finalizeThinkingBlock(context) - updateStreamingMessage(set, context) - abortInProgressTools(set, get) - }, - default: () => {}, -} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts index 7d62753209d..b989ea34396 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts @@ -2,13 +2,14 @@ import { db } from '@sim/db' import { permissions, workspace } from '@sim/db/schema' import { and, desc, eq, isNull } from 'drizzle-orm' import { authorizeWorkflowByWorkspacePermission, type getWorkflowById } from '@/lib/workflows/utils' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' type WorkflowRecord = NonNullable>> export async function ensureWorkflowAccess( workflowId: string, - userId: string + userId: string, + action: 'read' | 'write' | 'admin' = 'read' ): Promise<{ workflow: WorkflowRecord workspaceId?: string | null @@ -16,7 +17,7 @@ export async function ensureWorkflowAccess( const result = await authorizeWorkflowByWorkspacePermission({ workflowId, userId, - action: 'read', + action, }) if (!result.workflow) { @@ -56,25 +57,25 @@ export async function getDefaultWorkspaceId(userId: string): Promise { export async function ensureWorkspaceAccess( workspaceId: string, userId: string, - requireWrite: boolean + level: 'read' | 'write' | 'admin' = 'read' ): Promise { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } - const permissionType = access.canWrite - ? 'write' - : access.workspace?.ownerId === userId - ? 'admin' - : 'read' - const canWrite = permissionType === 'admin' || permissionType === 'write' + if (level === 'read') return - if (requireWrite && !canWrite) { - throw new Error('Write or admin access required for this workspace') + if (level === 'admin') { + if (access.workspace?.ownerId === userId) return + const perm = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (perm !== 'admin') { + throw new Error('Admin access required for this workspace') + } + return } - if (!requireWrite && !canWrite && permissionType !== 'read') { - throw new Error('Access denied to workspace') + if (!access.canWrite) { + throw new Error('Write or admin access required for this workspace') } } diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts index 1d830cdfd34..062bedaceb6 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts @@ -2,15 +2,18 @@ import crypto from 'crypto' import { db } from '@sim/db' import { chat, workflowMcpTool } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { mcpPubSub } from '@/lib/mcp/pubsub' -import { - generateParameterSchemaForWorkflow, - removeMcpToolsForWorkflow, -} from '@/lib/mcp/workflow-mcp-sync' +import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/persistence/utils' +import { + performChatDeploy, + performChatUndeploy, + performFullDeploy, + performFullUndeploy, +} from '@/lib/workflows/orchestration' import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' @@ -25,20 +28,23 @@ export async function executeDeployApi( return { success: false, error: 'workflowId is required' } } const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'admin' + ) if (action === 'undeploy') { - const result = await undeployWorkflow({ workflowId }) + const result = await performFullUndeploy({ workflowId, userId: context.userId }) if (!result.success) { return { success: false, error: result.error || 'Failed to undeploy workflow' } } - await removeMcpToolsForWorkflow(workflowId, crypto.randomUUID().slice(0, 8)) return { success: true, output: { workflowId, isDeployed: false } } } - const result = await deployWorkflow({ + const result = await performFullDeploy({ workflowId, - deployedBy: context.userId, + userId: context.userId, workflowName: workflowRecord.name || undefined, }) if (!result.success) { @@ -82,11 +88,21 @@ export async function executeDeployChat( if (!existing.length) { return { success: false, error: 'No active chat deployment found for this workflow' } } - const { hasAccess } = await checkChatAccess(existing[0].id, context.userId) + const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess( + existing[0].id, + context.userId + ) if (!hasAccess) { return { success: false, error: 'Unauthorized chat access' } } - await db.delete(chat).where(eq(chat.id, existing[0].id)) + const undeployResult = await performChatUndeploy({ + chatId: existing[0].id, + userId: context.userId, + workspaceId: chatWorkspaceId, + }) + if (!undeployResult.success) { + return { success: false, error: undeployResult.error || 'Failed to undeploy chat' } + } return { success: true, output: { @@ -99,17 +115,19 @@ export async function executeDeployChat( } } - const { hasAccess } = await checkWorkflowAccessForChatCreation(workflowId, context.userId) - if (!hasAccess) { + const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation( + workflowId, + context.userId + ) + if (!hasAccess || !workflowRecord) { return { success: false, error: 'Workflow not found or access denied' } } - const existing = await db + const [existingDeployment] = await db .select() .from(chat) .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) .limit(1) - const existingDeployment = existing[0] || null const identifier = String(params.identifier || existingDeployment?.identifier || '').trim() const title = String(params.title || existingDeployment?.title || '').trim() @@ -134,21 +152,14 @@ export async function executeDeployChat( return { success: false, error: 'Identifier already in use' } } - const deployResult = await deployWorkflow({ - workflowId, - deployedBy: context.userId, - }) - if (!deployResult.success) { - return { success: false, error: deployResult.error || 'Failed to deploy workflow' } - } - const existingCustomizations = (existingDeployment?.customizations as | { primaryColor?: string; welcomeMessage?: string } | undefined) || {} - const payload = { + const result = await performChatDeploy({ workflowId, + userId: context.userId, identifier, title, description: String(params.description || existingDeployment?.description || ''), @@ -162,46 +173,22 @@ export async function executeDeployChat( existingCustomizations.welcomeMessage || 'Hi there! How can I help you today?', }, - authType: params.authType || existingDeployment?.authType || 'public', + authType: (params.authType || existingDeployment?.authType || 'public') as + | 'public' + | 'password' + | 'email' + | 'sso', password: params.password, - allowedEmails: params.allowedEmails || existingDeployment?.allowedEmails || [], - outputConfigs: params.outputConfigs || existingDeployment?.outputConfigs || [], - } + allowedEmails: params.allowedEmails || (existingDeployment?.allowedEmails as string[]) || [], + outputConfigs: (params.outputConfigs || existingDeployment?.outputConfigs || []) as Array<{ + blockId: string + path: string + }>, + workspaceId: workflowRecord.workspaceId, + }) - if (existingDeployment) { - await db - .update(chat) - .set({ - identifier: payload.identifier, - title: payload.title, - description: payload.description, - customizations: payload.customizations, - authType: payload.authType, - password: payload.password || existingDeployment.password, - allowedEmails: - payload.authType === 'email' || payload.authType === 'sso' ? payload.allowedEmails : [], - outputConfigs: payload.outputConfigs, - updatedAt: new Date(), - }) - .where(eq(chat.id, existingDeployment.id)) - } else { - await db.insert(chat).values({ - id: crypto.randomUUID(), - workflowId, - userId: context.userId, - identifier: payload.identifier, - title: payload.title, - description: payload.description, - customizations: payload.customizations, - isActive: true, - authType: payload.authType, - password: payload.password || null, - allowedEmails: - payload.authType === 'email' || payload.authType === 'sso' ? payload.allowedEmails : [], - outputConfigs: payload.outputConfigs, - createdAt: new Date(), - updatedAt: new Date(), - }) + if (!result.success) { + return { success: false, error: result.error || 'Failed to deploy chat' } } const baseUrl = getBaseUrl() @@ -214,7 +201,7 @@ export async function executeDeployChat( isDeployed: true, isChatDeployed: true, identifier, - chatUrl: `${baseUrl}/chat/${identifier}`, + chatUrl: result.chatUrl, apiEndpoint: `${baseUrl}/api/workflows/${workflowId}/run`, baseUrl, }, @@ -234,7 +221,11 @@ export async function executeDeployMcp( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'admin' + ) const workspaceId = workflowRecord.workspaceId if (!workspaceId) { return { success: false, error: 'workspaceId is required' } @@ -263,8 +254,15 @@ export async function executeDeployMcp( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - // Intentionally omits `isDeployed` — removing from an MCP server does not - // affect the workflow's API deployment. + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Undeployed workflow "${workflowId}" from MCP server`, + }) + return { success: true, output: { workflowId, serverId, action: 'undeploy', removed: true }, @@ -319,6 +317,15 @@ export async function executeDeployMcp( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Updated MCP tool "${toolName}" on server`, + }) + return { success: true, output: { toolId, toolName, toolDescription, updated: true, mcpServerUrl, baseUrl }, @@ -339,6 +346,15 @@ export async function executeDeployMcp( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Deployed workflow as MCP tool "${toolName}"`, + }) + return { success: true, output: { toolId, toolName, toolDescription, updated: false, mcpServerUrl, baseUrl }, @@ -357,9 +373,9 @@ export async function executeRedeploy( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context.userId, 'admin') - const result = await deployWorkflow({ workflowId, deployedBy: context.userId }) + const result = await performFullDeploy({ workflowId, userId: context.userId }) if (!result.success) { return { success: false, error: result.error || 'Failed to redeploy workflow' } } diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts index 6669867b05a..ecc2456d544 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts @@ -8,12 +8,13 @@ import { workflowMcpTool, } from '@sim/db/schema' import { and, eq, inArray, isNull } from 'drizzle-orm' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' -import { ensureWorkflowAccess } from '../access' +import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access' import type { CheckDeploymentStatusParams, CreateWorkspaceMcpServerParams, @@ -182,7 +183,11 @@ export async function executeCreateWorkspaceMcpServer( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'write' + ) const workspaceId = workflowRecord.workspaceId if (!workspaceId) { return { success: false, error: 'workspaceId is required' } @@ -242,6 +247,16 @@ export async function executeCreateWorkspaceMcpServer( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) } + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: name, + description: `Created MCP server "${name}"`, + }) + return { success: true, output: { server, addedTools } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -277,7 +292,10 @@ export async function executeUpdateWorkspaceMcpServer( } const [existing] = await db - .select({ id: workflowMcpServer.id, createdBy: workflowMcpServer.createdBy }) + .select({ + id: workflowMcpServer.id, + workspaceId: workflowMcpServer.workspaceId, + }) .from(workflowMcpServer) .where(eq(workflowMcpServer.id, serverId)) .limit(1) @@ -286,8 +304,18 @@ export async function executeUpdateWorkspaceMcpServer( return { success: false, error: 'MCP server not found' } } + await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'write') + await db.update(workflowMcpServer).set(updates).where(eq(workflowMcpServer.id, serverId)) + recordAudit({ + actorId: context.userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Updated MCP server`, + }) + return { success: true, output: { serverId, ...updates, updatedAt: undefined } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -318,10 +346,20 @@ export async function executeDeleteWorkspaceMcpServer( return { success: false, error: 'MCP server not found' } } + await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'admin') + await db.delete(workflowMcpServer).where(eq(workflowMcpServer.id, serverId)) mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId: existing.workspaceId }) + recordAudit({ + actorId: context.userId, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Deleted MCP server`, + }) + return { success: true, output: { serverId, name: existing.name, deleted: true } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -379,7 +417,7 @@ export async function executeRevertToVersion( return { success: false, error: 'version is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context.userId, 'admin') const baseUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || 'http://localhost:3000' diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index e824f25a064..95252daf8bf 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { credential, mcpServers, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, lt } from 'drizzle-orm' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import type { ExecutionContext, @@ -231,6 +232,16 @@ async function executeManageCustomTool( }) const created = resultTools.find((tool) => tool.title === title) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created?.id, + resourceName: title, + description: `Created custom tool "${title}"`, + }) + return { success: true, output: { @@ -279,6 +290,16 @@ async function executeManageCustomTool( userId: context.userId, }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: params.toolId, + resourceName: title, + description: `Updated custom tool "${title}"`, + }) + return { success: true, output: { @@ -305,6 +326,15 @@ async function executeManageCustomTool( return { success: false, error: `Custom tool not found: ${params.toolId}` } } + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: params.toolId, + description: 'Deleted custom tool', + }) + return { success: true, output: { @@ -461,6 +491,18 @@ async function executeManageMcpTool( await mcpService.clearCache(workspaceId) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: config.name, + description: existing + ? `Updated existing MCP server "${config.name}"` + : `Added MCP server "${config.name}"`, + }) + return { success: true, output: { @@ -514,6 +556,15 @@ async function executeManageMcpTool( await mcpService.clearCache(workspaceId) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Updated MCP server "${updated.name}"`, + }) + return { success: true, output: { @@ -527,6 +578,13 @@ async function executeManageMcpTool( } if (operation === 'delete') { + if (context.userPermission && context.userPermission !== 'admin') { + return { + success: false, + error: `Permission denied: 'delete' on manage_mcp_tool requires admin access. You have '${context.userPermission}' permission.`, + } + } + if (!params.serverId) { return { success: false, error: "'serverId' is required for 'delete'" } } @@ -542,6 +600,15 @@ async function executeManageMcpTool( await mcpService.clearCache(workspaceId) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Deleted MCP server "${deleted.name}"`, + }) + return { success: true, output: { @@ -643,6 +710,16 @@ async function executeManageSkill( }) const created = resultSkills.find((s) => s.name === params.name) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.SKILL_CREATED, + resourceType: AuditResourceType.SKILL, + resourceId: created?.id, + resourceName: params.name, + description: `Created skill "${params.name}"`, + }) + return { success: true, output: { @@ -685,14 +762,26 @@ async function executeManageSkill( userId: context.userId, }) + const updatedName = params.name || found.name + + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.SKILL_UPDATED, + resourceType: AuditResourceType.SKILL, + resourceId: params.skillId, + resourceName: updatedName, + description: `Updated skill "${updatedName}"`, + }) + return { success: true, output: { success: true, operation, skillId: params.skillId, - name: params.name || found.name, - message: `Updated skill "${params.name || found.name}"`, + name: updatedName, + message: `Updated skill "${updatedName}"`, }, } } @@ -707,6 +796,15 @@ async function executeManageSkill( return { success: false, error: `Skill not found: ${params.skillId}` } } + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.SKILL_DELETED, + resourceType: AuditResourceType.SKILL, + resourceId: params.skillId, + description: 'Deleted skill', + }) + return { success: true, output: { @@ -951,10 +1049,24 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record< .update(credential) .set({ displayName, updatedAt: new Date() }) .where(eq(credential.id, credentialId)) + recordAudit({ + actorId: c.userId, + action: AuditAction.CREDENTIAL_RENAMED, + resourceType: AuditResourceType.OAUTH, + resourceId: credentialId, + description: `Renamed credential to "${displayName}"`, + }) return { success: true, output: { credentialId, displayName } } } case 'delete': { await db.delete(credential).where(eq(credential.id, credentialId)) + recordAudit({ + actorId: c.userId, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.OAUTH, + resourceId: credentialId, + description: `Deleted credential`, + }) return { success: true, output: { credentialId, deleted: true } } } default: diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts index f9c48ab5beb..6870bde0d50 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts @@ -3,6 +3,7 @@ import { copilotChats, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { parseCronToHumanReadable, validateCronExpression } from '@/lib/workflows/schedules/utils' @@ -150,6 +151,17 @@ export async function executeCreateJob( logger.info('Job created', { jobId, cronExpression, nextRunAt: nextRunAt.toISOString() }) + recordAudit({ + workspaceId: context.workspaceId || null, + actorId: context.userId, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: jobId, + resourceName: title || undefined, + description: `Created job "${title || jobId}"`, + metadata: { operation: 'create', cronExpression }, + }) + return { success: true, output: { @@ -381,6 +393,19 @@ export async function executeManageJob( logger.info('Job updated', { jobId: args.jobId, fields: Object.keys(updates) }) + recordAudit({ + workspaceId: context.workspaceId || null, + actorId: context.userId, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: args.jobId, + description: `Updated job`, + metadata: { + operation: 'update', + fields: Object.keys(updates).filter((k) => k !== 'updatedAt'), + }, + }) + return { success: true, output: { @@ -419,6 +444,16 @@ export async function executeManageJob( logger.info('Job deleted', { jobId: args.jobId }) + recordAudit({ + workspaceId: context.workspaceId || null, + actorId: context.userId, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: args.jobId, + description: `Deleted job`, + metadata: { operation: 'delete' }, + }) + return { success: true, output: { @@ -492,6 +527,16 @@ export async function executeCompleteJob( logger.info('Job completed', { jobId }) + recordAudit({ + workspaceId: context.workspaceId || null, + actorId: context.userId, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: jobId, + description: `Completed job`, + metadata: { operation: 'complete' }, + }) + return { success: true, output: { jobId, message: 'Job marked as completed. No further executions will occur.' }, diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts index 5428e4dff8a..2fce7e695ad 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/orchestrator/tool-executor/upload-file-reader' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { getServePathPrefix } from '@/lib/uploads' @@ -158,6 +159,17 @@ async function executeImport( chatId, }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: dedupedName, + description: `Imported workflow "${dedupedName}" from file`, + metadata: { fileName, source: 'copilot-import' }, + }) + return { success: true, output: { diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts index ad43e8f5ab2..c30d6b3e578 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { createWorkspaceApiKey } from '@/lib/api-key/auth' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { generateRequestId } from '@/lib/core/utils/request' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' @@ -8,13 +9,13 @@ import { getExecutionState, getLatestExecutionState, } from '@/lib/workflows/executor/execution-state' +import { performDeleteFolder, performDeleteWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { + checkForCircularReference, createFolderRecord, createWorkflowRecord, - deleteFolderRecord, - deleteWorkflowRecord, listFolders, setWorkflowVariables, updateFolderRecord, @@ -121,7 +122,7 @@ export async function executeCreateWorkflow( params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) const folderId = params?.folderId || null - await ensureWorkspaceAccess(workspaceId, context.userId, true) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertWorkflowMutationNotAborted(context) const result = await createWorkflowRecord({ @@ -132,6 +133,28 @@ export async function executeCreateWorkflow( folderId, }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: name, + description: `Created workflow "${name}"`, + }) + + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.workflowCreated({ + workflowId: result.workflowId, + name, + workspaceId, + folderId: folderId ?? undefined, + }) + } catch (_e) { + // Telemetry is best-effort + } + const normalized = await loadWorkflowFromNormalizedTables(result.workflowId) let copilotSanitizedWorkflowState: unknown if (normalized) { @@ -175,7 +198,7 @@ export async function executeCreateFolder( params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) const parentId = params?.parentId || null - await ensureWorkspaceAccess(workspaceId, context.userId, true) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertWorkflowMutationNotAborted(context) const result = await createFolderRecord({ @@ -185,6 +208,16 @@ export async function executeCreateFolder( parentId, }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folderId, + resourceName: name, + description: `Created folder "${name}"`, + }) + return { success: true, output: result } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -201,7 +234,11 @@ export async function executeRunWorkflow( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'write' + ) const useDraftState = !params.useDeployedState @@ -236,7 +273,11 @@ export async function executeSetGlobalWorkflowVariables( const operations: VariableOperation[] = Array.isArray(params.operations) ? params.operations : [] - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'write' + ) interface WorkflowVariable { id: string @@ -325,6 +366,14 @@ export async function executeSetGlobalWorkflowVariables( assertWorkflowMutationNotAborted(context) await setWorkflowVariables(workflowId, nextVarsRecord) + recordAudit({ + actorId: context.userId, + action: AuditAction.WORKFLOW_VARIABLES_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + description: `Updated workflow variables`, + }) + return { success: true, output: { updated: Object.values(byName).length } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -348,7 +397,7 @@ export async function executeRenameWorkflow( return { success: false, error: 'Workflow name must be 200 characters or less' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context.userId, 'write') assertWorkflowMutationNotAborted(context) await updateWorkflowRecord(workflowId, { name }) @@ -368,7 +417,7 @@ export async function executeMoveWorkflow( return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context.userId, 'write') const folderId = params.folderId || null assertWorkflowMutationNotAborted(context) await updateWorkflowRecord(workflowId, { folderId }) @@ -395,6 +444,15 @@ export async function executeMoveFolder( return { success: false, error: 'A folder cannot be moved into itself' } } + if (parentId) { + const wouldCreateCycle = await checkForCircularReference(folderId, parentId) + if (wouldCreateCycle) { + return { success: false, error: 'Cannot create circular folder reference' } + } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertWorkflowMutationNotAborted(context) await updateFolderRecord(folderId, { parentId }) @@ -417,7 +475,11 @@ export async function executeRunWorkflowUntilBlock( return { success: false, error: 'stopAfterBlockId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'write' + ) const useDraftState = !params.useDeployedState @@ -460,7 +522,7 @@ export async function executeGenerateApiKey( const workspaceId = params.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, true) + await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') assertWorkflowMutationNotAborted(context) const newKey = await createWorkspaceApiKey({ @@ -469,6 +531,14 @@ export async function executeGenerateApiKey( name, }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + description: `Generated API key for workspace`, + }) + return { success: true, output: { @@ -511,7 +581,11 @@ export async function executeRunFromBlock( } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'write' + ) const useDraftState = !params.useDeployedState const result = await executeWorkflow( @@ -569,7 +643,7 @@ export async function executeUpdateWorkflow( return { success: false, error: 'At least one of name or description is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context.userId, 'write') assertWorkflowMutationNotAborted(context) await updateWorkflowRecord(workflowId, updates) @@ -592,9 +666,17 @@ export async function executeDeleteWorkflow( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'admin' + ) assertWorkflowMutationNotAborted(context) - await deleteWorkflowRecord(workflowId) + + const result = await performDeleteWorkflow({ workflowId, userId: context.userId }) + if (!result.success) { + return { success: false, error: result.error || 'Failed to delete workflow' } + } return { success: true, @@ -616,7 +698,7 @@ export async function executeDeleteFolder( } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, true) + await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') const folders = await listFolders(workspaceId) const folder = folders.find((f) => f.folderId === folderId) @@ -625,12 +707,19 @@ export async function executeDeleteFolder( } assertWorkflowMutationNotAborted(context) - const deleted = await deleteFolderRecord(folderId) - if (!deleted) { - return { success: false, error: 'Folder not found' } + + const result = await performDeleteFolder({ + folderId, + workspaceId, + userId: context.userId, + folderName: folder.folderName, + }) + + if (!result.success) { + return { success: false, error: result.error || 'Failed to delete folder' } } - return { success: true, output: { folderId, deleted: true } } + return { success: true, output: { folderId, deleted: true, ...result.deletedItems } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } @@ -653,6 +742,8 @@ export async function executeRenameFolder( return { success: false, error: 'Folder name must be 200 characters or less' } } + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertWorkflowMutationNotAborted(context) await updateFolderRecord(folderId, { name }) @@ -688,7 +779,11 @@ export async function executeRunBlock( } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'write' + ) const useDraftState = !params.useDeployedState const result = await executeWorkflow( diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/queries.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/queries.ts index d2ed986c634..2148a2c0cb1 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/queries.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/queries.ts @@ -46,7 +46,7 @@ export async function executeListFolders( context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, false) + await ensureWorkspaceAccess(workspaceId, context.userId, 'read') const folders = await listFolders(workspaceId) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts new file mode 100644 index 00000000000..8eda5054a86 --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -0,0 +1,206 @@ +import crypto from 'crypto' +import { db } from '@sim/db' +import { chat } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { encryptSecret } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { performFullDeploy } from '@/lib/workflows/orchestration/deploy' + +const logger = createLogger('ChatDeployOrchestration') + +export interface ChatDeployPayload { + workflowId: string + userId: string + identifier: string + title: string + description?: string + customizations?: { primaryColor?: string; welcomeMessage?: string; imageUrl?: string } + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string | null + allowedEmails?: string[] + outputConfigs?: Array<{ blockId: string; path: string }> + workspaceId?: string | null +} + +export interface PerformChatDeployResult { + success: boolean + chatId?: string + chatUrl?: string + error?: string +} + +/** + * Deploys a chat: deploys the underlying workflow via `performFullDeploy`, + * encrypts passwords, creates or updates the chat record, fires telemetry, + * and records an audit entry. Both the chat API route and the copilot + * `deploy_chat` tool must use this function. + */ +export async function performChatDeploy( + params: ChatDeployPayload +): Promise { + const { + workflowId, + userId, + identifier, + title, + description = '', + authType = 'public', + password, + allowedEmails = [], + outputConfigs = [], + } = params + + const customizations = { + primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)', + welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?', + ...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}), + } + + const deployResult = await performFullDeploy({ workflowId, userId }) + if (!deployResult.success) { + return { success: false, error: deployResult.error || 'Failed to deploy workflow' } + } + + let encryptedPassword: string | null = null + if (authType === 'password' && password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + } + + const [existingDeployment] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .limit(1) + + let chatId: string + if (existingDeployment) { + chatId = existingDeployment.id + + let passwordToStore: string | null + if (authType === 'password') { + passwordToStore = encryptedPassword || existingDeployment.password + } else { + passwordToStore = null + } + + await db + .update(chat) + .set({ + identifier, + title, + description: description || null, + customizations, + authType, + password: passwordToStore, + allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], + outputConfigs, + updatedAt: new Date(), + }) + .where(eq(chat.id, chatId)) + } else { + chatId = crypto.randomUUID() + await db.insert(chat).values({ + id: chatId, + workflowId, + userId, + identifier, + title, + description: description || null, + customizations, + isActive: true, + authType, + password: encryptedPassword, + allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], + outputConfigs, + createdAt: new Date(), + updatedAt: new Date(), + }) + } + + const baseUrl = getBaseUrl() + let chatUrl: string + try { + const url = new URL(baseUrl) + let host = url.host + if (host.startsWith('www.')) { + host = host.substring(4) + } + chatUrl = `${url.protocol}//${host}/chat/${identifier}` + } catch { + chatUrl = `${baseUrl}/chat/${identifier}` + } + + logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) + + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.chatDeployed({ + chatId, + workflowId, + authType, + hasOutputConfigs: outputConfigs.length > 0, + }) + } catch (_e) { + // Telemetry is best-effort + } + + recordAudit({ + workspaceId: params.workspaceId || null, + actorId: userId, + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: title, + description: `Deployed chat "${title}"`, + metadata: { workflowId, identifier, authType }, + }) + + return { success: true, chatId, chatUrl } +} + +export interface PerformChatUndeployParams { + chatId: string + userId: string + workspaceId?: string | null +} + +export interface PerformChatUndeployResult { + success: boolean + error?: string +} + +/** + * Undeploys a chat: deletes the chat record and records an audit entry. + * Both the chat manage DELETE route and the copilot `deploy_chat` undeploy + * action must use this function. + */ +export async function performChatUndeploy( + params: PerformChatUndeployParams +): Promise { + const { chatId, userId, workspaceId } = params + + const [chatRecord] = await db.select().from(chat).where(eq(chat.id, chatId)).limit(1) + + if (!chatRecord) { + return { success: false, error: 'Chat not found' } + } + + await db.delete(chat).where(eq(chat.id, chatId)) + + logger.info(`Chat "${chatId}" deleted successfully`) + + recordAudit({ + workspaceId: workspaceId || null, + actorId: userId, + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: chatRecord.title || chatId, + description: `Deleted chat deployment "${chatRecord.title || chatId}"`, + }) + + return { success: true } +} diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts new file mode 100644 index 00000000000..5e8863ccb0b --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -0,0 +1,484 @@ +import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { generateRequestId } from '@/lib/core/utils/request' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' +import { + cleanupWebhooksForWorkflow, + restorePreviousVersionWebhooks, + saveTriggerWebhooksForDeploy, +} from '@/lib/webhooks/deploy' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' +import { + activateWorkflowVersion, + activateWorkflowVersionById, + deployWorkflow, + loadWorkflowFromNormalizedTables, + undeployWorkflow, +} from '@/lib/workflows/persistence/utils' +import { + cleanupDeploymentVersion, + createSchedulesForDeploy, + validateWorkflowSchedules, +} from '@/lib/workflows/schedules' + +const logger = createLogger('DeployOrchestration') + +export interface PerformFullDeployParams { + workflowId: string + userId: string + workflowName?: string + requestId?: string + /** + * Optional NextRequest for external webhook subscriptions. + * If not provided, a synthetic request is constructed from the base URL. + */ + request?: NextRequest + /** + * Override the actor ID used in audit logs and the `deployedBy` field. + * Defaults to `userId`. Use `'admin-api'` for admin-initiated actions. + */ + actorId?: string +} + +export interface PerformFullDeployResult { + success: boolean + deployedAt?: Date + version?: number + deploymentVersionId?: string + error?: string + errorCode?: OrchestrationErrorCode + warnings?: string[] +} + +/** + * Performs a full workflow deployment: creates a deployment version, syncs + * trigger webhooks, creates schedules, cleans up the previous version, and + * syncs MCP tools. Both the deploy API route and the copilot deploy tools + * must use this single function so behaviour stays consistent. + */ +export async function performFullDeploy( + params: PerformFullDeployParams +): Promise { + const { workflowId, userId, workflowName } = params + const actorId = params.actorId ?? userId + const requestId = params.requestId ?? generateRequestId() + const request = params.request ?? new NextRequest(new URL('/api/webhooks', getBaseUrl())) + + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalizedData) { + return { success: false, error: 'Failed to load workflow state', errorCode: 'not_found' } + } + + const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks) + if (!scheduleValidation.isValid) { + return { + success: false, + error: `Invalid schedule configuration: ${scheduleValidation.error}`, + errorCode: 'validation', + } + } + + const [workflowRecord] = await db + .select() + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!workflowRecord) { + return { success: false, error: 'Workflow not found', errorCode: 'not_found' } + } + + const workflowData = workflowRecord as Record + + const [currentActiveVersion] = await db + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .limit(1) + const previousVersionId = currentActiveVersion?.id + + const rollbackDeployment = async () => { + if (previousVersionId) { + await restorePreviousVersionWebhooks({ + request, + workflow: workflowData, + userId, + previousVersionId, + requestId, + }) + const reactivateResult = await activateWorkflowVersionById({ + workflowId, + deploymentVersionId: previousVersionId, + }) + if (reactivateResult.success) return + } + await undeployWorkflow({ workflowId }) + } + + const deployResult = await deployWorkflow({ + workflowId, + deployedBy: actorId, + workflowName: workflowName || workflowRecord.name || undefined, + }) + + if (!deployResult.success) { + return { success: false, error: deployResult.error || 'Failed to deploy workflow' } + } + + const deployedAt = deployResult.deployedAt! + const deploymentVersionId = deployResult.deploymentVersionId + + if (!deploymentVersionId) { + await undeployWorkflow({ workflowId }) + return { success: false, error: 'Failed to resolve deployment version' } + } + + const triggerSaveResult = await saveTriggerWebhooksForDeploy({ + request, + workflowId, + workflow: workflowData, + userId, + blocks: normalizedData.blocks, + requestId, + deploymentVersionId, + previousVersionId, + }) + + if (!triggerSaveResult.success) { + await cleanupDeploymentVersion({ + workflowId, + workflow: workflowData, + requestId, + deploymentVersionId, + }) + await rollbackDeployment() + return { + success: false, + error: triggerSaveResult.error?.message || 'Failed to save trigger configuration', + } + } + + const scheduleResult = await createSchedulesForDeploy( + workflowId, + normalizedData.blocks, + db, + deploymentVersionId + ) + if (!scheduleResult.success) { + logger.error(`[${requestId}] Failed to create schedule: ${scheduleResult.error}`) + await cleanupDeploymentVersion({ + workflowId, + workflow: workflowData, + requestId, + deploymentVersionId, + }) + await rollbackDeployment() + return { success: false, error: scheduleResult.error || 'Failed to create schedule' } + } + + if (previousVersionId && previousVersionId !== deploymentVersionId) { + try { + await cleanupDeploymentVersion({ + workflowId, + workflow: workflowData, + requestId, + deploymentVersionId: previousVersionId, + skipExternalCleanup: true, + }) + } catch (cleanupError) { + logger.error(`[${requestId}] Failed to clean up previous version`, cleanupError) + } + } + + await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' }) + + recordAudit({ + workspaceId: (workflowData.workspaceId as string) || null, + actorId: actorId, + action: AuditAction.WORKFLOW_DEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: (workflowData.name as string) || undefined, + description: `Deployed workflow "${(workflowData.name as string) || workflowId}"`, + metadata: { version: deploymentVersionId }, + request, + }) + + return { + success: true, + deployedAt, + version: deployResult.version, + deploymentVersionId, + warnings: triggerSaveResult.warnings, + } +} + +export interface PerformFullUndeployParams { + workflowId: string + userId: string + requestId?: string + /** Override the actor ID used in audit logs. Defaults to `userId`. */ + actorId?: string +} + +export interface PerformFullUndeployResult { + success: boolean + error?: string +} + +/** + * Performs a full workflow undeploy: marks the workflow as undeployed, cleans up + * webhook records and external subscriptions, removes MCP tools, emits a + * telemetry event, and records an audit log entry. Both the deploy API DELETE + * handler and the copilot undeploy tools must use this single function. + */ +export async function performFullUndeploy( + params: PerformFullUndeployParams +): Promise { + const { workflowId, userId } = params + const actorId = params.actorId ?? userId + const requestId = params.requestId ?? generateRequestId() + + const [workflowRecord] = await db + .select() + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!workflowRecord) { + return { success: false, error: 'Workflow not found' } + } + + const workflowData = workflowRecord as Record + + const result = await undeployWorkflow({ workflowId }) + if (!result.success) { + return { success: false, error: result.error || 'Failed to undeploy workflow' } + } + + await cleanupWebhooksForWorkflow(workflowId, workflowData, requestId) + await removeMcpToolsForWorkflow(workflowId, requestId) + + logger.info(`[${requestId}] Workflow undeployed successfully: ${workflowId}`) + + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.workflowUndeployed({ workflowId }) + } catch (_e) { + // Telemetry is best-effort + } + + recordAudit({ + workspaceId: (workflowData.workspaceId as string) || null, + actorId: actorId, + action: AuditAction.WORKFLOW_UNDEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: (workflowData.name as string) || undefined, + description: `Undeployed workflow "${(workflowData.name as string) || workflowId}"`, + }) + + return { success: true } +} + +export interface PerformActivateVersionParams { + workflowId: string + version: number + userId: string + workflow: Record + requestId?: string + request?: NextRequest + /** Override the actor ID used in audit logs. Defaults to `userId`. */ + actorId?: string +} + +export interface PerformActivateVersionResult { + success: boolean + deployedAt?: Date + error?: string + errorCode?: OrchestrationErrorCode + warnings?: string[] +} + +/** + * Activates an existing deployment version: validates schedules, syncs trigger + * webhooks (with forced subscription recreation), creates schedules, activates + * the version, cleans up the previous version, syncs MCP tools, and records + * an audit entry. Both the deployment version PATCH handler and the admin + * activate route must use this function. + */ +export async function performActivateVersion( + params: PerformActivateVersionParams +): Promise { + const { workflowId, version, userId, workflow } = params + const actorId = params.actorId ?? userId + const requestId = params.requestId ?? generateRequestId() + const request = params.request ?? new NextRequest(new URL('/api/webhooks', getBaseUrl())) + + const [versionRow] = await db + .select({ + id: workflowDeploymentVersion.id, + state: workflowDeploymentVersion.state, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.version, version) + ) + ) + .limit(1) + + if (!versionRow?.state) { + return { success: false, error: 'Deployment version not found', errorCode: 'not_found' } + } + + const deployedState = versionRow.state as { blocks?: Record } + const blocks = deployedState.blocks + if (!blocks || typeof blocks !== 'object') { + return { success: false, error: 'Invalid deployed state structure', errorCode: 'validation' } + } + + const [currentActiveVersion] = await db + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .limit(1) + const previousVersionId = currentActiveVersion?.id + + const scheduleValidation = validateWorkflowSchedules( + blocks as Record + ) + if (!scheduleValidation.isValid) { + return { + success: false, + error: `Invalid schedule configuration: ${scheduleValidation.error}`, + errorCode: 'validation', + } + } + + const triggerSaveResult = await saveTriggerWebhooksForDeploy({ + request, + workflowId, + workflow, + userId, + blocks: blocks as Record, + requestId, + deploymentVersionId: versionRow.id, + previousVersionId, + forceRecreateSubscriptions: true, + }) + + if (!triggerSaveResult.success) { + if (previousVersionId) { + await restorePreviousVersionWebhooks({ + request, + workflow, + userId, + previousVersionId, + requestId, + }) + } + return { + success: false, + error: triggerSaveResult.error?.message || 'Failed to sync trigger configuration', + } + } + + const scheduleResult = await createSchedulesForDeploy( + workflowId, + blocks as Record, + db, + versionRow.id + ) + + if (!scheduleResult.success) { + await cleanupDeploymentVersion({ + workflowId, + workflow, + requestId, + deploymentVersionId: versionRow.id, + }) + if (previousVersionId) { + await restorePreviousVersionWebhooks({ + request, + workflow, + userId, + previousVersionId, + requestId, + }) + } + return { success: false, error: scheduleResult.error || 'Failed to sync schedules' } + } + + const result = await activateWorkflowVersion({ workflowId, version }) + if (!result.success) { + await cleanupDeploymentVersion({ + workflowId, + workflow, + requestId, + deploymentVersionId: versionRow.id, + }) + if (previousVersionId) { + await restorePreviousVersionWebhooks({ + request, + workflow, + userId, + previousVersionId, + requestId, + }) + } + return { success: false, error: result.error || 'Failed to activate version' } + } + + if (previousVersionId && previousVersionId !== versionRow.id) { + try { + await cleanupDeploymentVersion({ + workflowId, + workflow, + requestId, + deploymentVersionId: previousVersionId, + skipExternalCleanup: true, + }) + } catch (cleanupError) { + logger.error(`[${requestId}] Failed to clean up previous version`, cleanupError) + } + } + + await syncMcpToolsForWorkflow({ + workflowId, + requestId, + state: versionRow.state as { blocks?: Record }, + context: 'activate', + }) + + recordAudit({ + workspaceId: (workflow.workspaceId as string) || null, + actorId: actorId, + action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + description: `Activated deployment version ${version}`, + metadata: { version }, + }) + + return { + success: true, + deployedAt: result.deployedAt, + warnings: triggerSaveResult.warnings, + } +} diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts new file mode 100644 index 00000000000..05bc2bc4173 --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -0,0 +1,155 @@ +import { db } from '@sim/db' +import { workflow, workflowFolder } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' + +const logger = createLogger('FolderLifecycle') + +/** + * Recursively deletes a folder: removes child folders first, archives non-archived + * workflows in each folder via {@link archiveWorkflowsByIdsInWorkspace}, then deletes + * the folder row. + */ +export async function deleteFolderRecursively( + folderId: string, + workspaceId: string +): Promise<{ folders: number; workflows: number }> { + const stats = { folders: 0, workflows: 0 } + + const childFolders = await db + .select({ id: workflowFolder.id }) + .from(workflowFolder) + .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) + + for (const childFolder of childFolders) { + const childStats = await deleteFolderRecursively(childFolder.id, workspaceId) + stats.folders += childStats.folders + stats.workflows += childStats.workflows + } + + const workflowsInFolder = await db + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + eq(workflow.folderId, folderId), + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt) + ) + ) + + if (workflowsInFolder.length > 0) { + await archiveWorkflowsByIdsInWorkspace( + workspaceId, + workflowsInFolder.map((entry) => entry.id), + { requestId: `folder-${folderId}` } + ) + stats.workflows += workflowsInFolder.length + } + + await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId)) + stats.folders += 1 + + return stats +} + +/** + * Counts non-archived workflows in the folder and all descendant folders. + */ +export async function countWorkflowsInFolderRecursively( + folderId: string, + workspaceId: string +): Promise { + let count = 0 + + const workflowsInFolder = await db + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + eq(workflow.folderId, folderId), + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt) + ) + ) + + count += workflowsInFolder.length + + const childFolders = await db + .select({ id: workflowFolder.id }) + .from(workflowFolder) + .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) + + for (const childFolder of childFolders) { + count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId) + } + + return count +} + +/** Parameters for {@link performDeleteFolder}. */ +export interface PerformDeleteFolderParams { + folderId: string + workspaceId: string + userId: string + folderName?: string +} + +/** Outcome of {@link performDeleteFolder}. */ +export interface PerformDeleteFolderResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + deletedItems?: { folders: number; workflows: number } +} + +/** + * Performs a full folder deletion: enforces the last-workflow guard, + * recursively archives child workflows and sub-folders, and records + * an audit entry. Both the folders API DELETE handler and the copilot + * delete_folder tool must use this function. + */ +export async function performDeleteFolder( + params: PerformDeleteFolderParams +): Promise { + const { folderId, workspaceId, userId, folderName } = params + + const workflowsInFolder = await countWorkflowsInFolderRecursively(folderId, workspaceId) + const totalWorkflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) + + if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) { + return { + success: false, + error: 'Cannot delete folder containing the only workflow(s) in the workspace', + errorCode: 'validation', + } + } + + const deletionStats = await deleteFolderRecursively(folderId, workspaceId) + + logger.info('Deleted folder and all contents:', { folderId, deletionStats }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName, + description: `Deleted folder "${folderName || folderId}"`, + metadata: { + affected: { + workflows: deletionStats.workflows, + subfolders: deletionStats.folders - 1, + }, + }, + }) + + return { success: true, deletedItems: deletionStats } +} diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts new file mode 100644 index 00000000000..76dbc07dc27 --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -0,0 +1,30 @@ +export { + type ChatDeployPayload, + type PerformChatDeployResult, + type PerformChatUndeployParams, + type PerformChatUndeployResult, + performChatDeploy, + performChatUndeploy, +} from './chat-deploy' +export { + type PerformActivateVersionParams, + type PerformActivateVersionResult, + type PerformFullDeployParams, + type PerformFullDeployResult, + type PerformFullUndeployParams, + type PerformFullUndeployResult, + performActivateVersion, + performFullDeploy, + performFullUndeploy, +} from './deploy' +export { + type PerformDeleteFolderParams, + type PerformDeleteFolderResult, + performDeleteFolder, +} from './folder-lifecycle' +export type { OrchestrationErrorCode } from './types' +export { + type PerformDeleteWorkflowParams, + type PerformDeleteWorkflowResult, + performDeleteWorkflow, +} from './workflow-lifecycle' diff --git a/apps/sim/lib/workflows/orchestration/types.ts b/apps/sim/lib/workflows/orchestration/types.ts new file mode 100644 index 00000000000..41939c80f74 --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/types.ts @@ -0,0 +1 @@ +export type OrchestrationErrorCode = 'validation' | 'not_found' | 'internal' diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts new file mode 100644 index 00000000000..3e757e6b2bf --- /dev/null +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -0,0 +1,118 @@ +import { db } from '@sim/db' +import { templates, workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { generateRequestId } from '@/lib/core/utils/request' +import { archiveWorkflow } from '@/lib/workflows/lifecycle' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' + +const logger = createLogger('WorkflowLifecycle') + +export interface PerformDeleteWorkflowParams { + workflowId: string + userId: string + requestId?: string + /** When 'delete', delete published templates. When 'orphan' (default), set their workflowId to null. */ + templateAction?: 'delete' | 'orphan' + /** When true, allows deleting the last workflow in a workspace (used by admin API). */ + skipLastWorkflowGuard?: boolean + /** Override the actor ID used in audit logs. Defaults to `userId`. */ + actorId?: string +} + +export interface PerformDeleteWorkflowResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode +} + +/** + * Performs a full workflow deletion: enforces the last-workflow guard, + * handles published templates, archives the workflow via `archiveWorkflow`, + * and records an audit entry. Both the workflow API DELETE handler and the + * copilot delete_workflow tool must use this function. + */ +export async function performDeleteWorkflow( + params: PerformDeleteWorkflowParams +): Promise { + const { workflowId, userId, templateAction = 'orphan', skipLastWorkflowGuard = false } = params + const actorId = params.actorId ?? userId + const requestId = params.requestId ?? generateRequestId() + + const [workflowRecord] = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowRecord) { + return { success: false, error: 'Workflow not found', errorCode: 'not_found' } + } + + if (!skipLastWorkflowGuard && workflowRecord.workspaceId) { + const totalWorkflows = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workflowRecord.workspaceId), isNull(workflow.archivedAt))) + + if (totalWorkflows.length <= 1) { + return { + success: false, + error: 'Cannot delete the only workflow in the workspace', + errorCode: 'validation', + } + } + } + + try { + const publishedTemplates = await db + .select({ id: templates.id }) + .from(templates) + .where(eq(templates.workflowId, workflowId)) + + if (publishedTemplates.length > 0) { + if (templateAction === 'delete') { + await db.delete(templates).where(eq(templates.workflowId, workflowId)) + logger.info( + `[${requestId}] Deleted ${publishedTemplates.length} templates for workflow ${workflowId}` + ) + } else { + await db + .update(templates) + .set({ workflowId: null }) + .where(eq(templates.workflowId, workflowId)) + logger.info( + `[${requestId}] Orphaned ${publishedTemplates.length} templates for workflow ${workflowId}` + ) + } + } + } catch (templateError) { + logger.warn(`[${requestId}] Failed to handle templates for workflow ${workflowId}`, { + error: templateError, + }) + } + + const archiveResult = await archiveWorkflow(workflowId, { requestId }) + if (!archiveResult.workflow) { + return { success: false, error: 'Workflow not found', errorCode: 'not_found' } + } + + logger.info(`[${requestId}] Successfully archived workflow ${workflowId}`) + + recordAudit({ + workspaceId: workflowRecord.workspaceId || null, + actorId: actorId, + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: workflowRecord.name, + description: `Archived workflow "${workflowRecord.name}"`, + metadata: { + archived: archiveResult.archived, + templateAction, + }, + }) + + return { success: true } +} diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index bf5cd48e30b..d7838b0ee41 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -616,6 +616,36 @@ export async function deleteFolderRecord(folderId: string): Promise { return true } +/** + * Checks whether setting `parentId` as the parent of `folderId` would + * create a circular reference in the folder tree. + */ +export async function checkForCircularReference( + folderId: string, + parentId: string +): Promise { + let currentParentId: string | null = parentId + const visited = new Set() + + while (currentParentId) { + if (visited.has(currentParentId) || currentParentId === folderId) { + return true + } + + visited.add(currentParentId) + + const [parent] = await db + .select({ parentId: workflowFolder.parentId }) + .from(workflowFolder) + .where(eq(workflowFolder.id, currentParentId)) + .limit(1) + + currentParentId = parent?.parentId || null + } + + return false +} + export async function listFolders(workspaceId: string) { return db .select({ diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 69b76442895..d44b82df206 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -22,6 +22,8 @@ export const auditMock = { CHAT_DEPLOYED: 'chat.deployed', CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + CREDENTIAL_DELETED: 'credential.deleted', + CREDENTIAL_RENAMED: 'credential.renamed', CREDIT_PURCHASED: 'credit.purchased', CREDENTIAL_SET_CREATED: 'credential_set.created', CREDENTIAL_SET_UPDATED: 'credential_set.updated', @@ -32,6 +34,9 @@ export const auditMock = { CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted', CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent', CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', + CUSTOM_TOOL_CREATED: 'custom_tool.created', + CUSTOM_TOOL_UPDATED: 'custom_tool.updated', + CUSTOM_TOOL_DELETED: 'custom_tool.deleted', CONNECTOR_DOCUMENT_RESTORED: 'connector_document.restored', CONNECTOR_DOCUMENT_EXCLUDED: 'connector_document.excluded', DOCUMENT_UPLOADED: 'document.uploaded', @@ -85,6 +90,9 @@ export const auditMock = { PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', SCHEDULE_UPDATED: 'schedule.updated', + SKILL_CREATED: 'skill.created', + SKILL_UPDATED: 'skill.updated', + SKILL_DELETED: 'skill.deleted', TABLE_CREATED: 'table.created', TABLE_UPDATED: 'table.updated', TABLE_DELETED: 'table.deleted', @@ -116,6 +124,7 @@ export const auditMock = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL_SET: 'credential_set', + CUSTOM_TOOL: 'custom_tool', DOCUMENT: 'document', ENVIRONMENT: 'environment', FILE: 'file', @@ -129,6 +138,7 @@ export const auditMock = { PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', + SKILL: 'skill', TABLE: 'table', TEMPLATE: 'template', WEBHOOK: 'webhook', From 7d4dd2676041f941d0f5d56ac3fe67aabca2c3b3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 30 Mar 2026 20:35:08 -0700 Subject: [PATCH 13/15] fix(knowledge): fix document processing stuck in processing state (#3857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(knowledge): fix document processing stuck in processing state * fix(knowledge): use Promise.allSettled for document dispatch and fix Copilot OAuth context - Change Promise.all to Promise.allSettled in processDocumentsWithQueue so one failed dispatch doesn't abort the entire batch - Add writeOAuthReturnContext before showing LazyOAuthRequiredModal from Copilot tools so useOAuthReturnForWorkflow can handle the return - Add consumeOAuthReturnContext on modal close to clean up stale context * fix(knowledge): fix type error in useCredentialRefreshTriggers call Pass empty string instead of undefined for connectorProviderId fallback to match the hook's string parameter type. * upgrade turbo * fix(knowledge): fix type error in connectors-section useCredentialRefreshTriggers call Same string narrowing fix as add-connector-modal — pass empty string fallback for providerId. --- .../[workspaceId]/knowledge/[id]/base.tsx | 6 +- .../add-connector-modal.tsx | 170 +++++++----------- .../connectors-section/connectors-section.tsx | 46 +++-- .../components/connect-credential-modal.tsx | 23 ++- .../credential-selector.tsx | 19 +- .../components/tools/credential-selector.tsx | 19 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 19 +- .../lib/knowledge/connectors/sync-engine.ts | 18 ++ apps/sim/lib/knowledge/documents/service.ts | 41 +++-- apps/sim/lib/knowledge/embeddings.ts | 8 +- apps/sim/lib/oauth/oauth.ts | 4 +- bun.lock | 16 +- package.json | 2 +- 13 files changed, 240 insertions(+), 151 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 90cababca5b..a8d6a80ca83 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -900,7 +900,11 @@ export function KnowledgeBase({ onClick={() => setShowConnectorsModal(true)} className='flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover-hover:bg-[var(--surface-3)]' > - {ConnectorIcon && } + {connector.status === 'syncing' ? ( + + ) : ( + ConnectorIcon && + )} {def?.name || connector.connectorType} ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 5144ce5d8ab..9e266ec4e80 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -19,21 +19,17 @@ import { ModalHeader, Tooltip, } from '@/components/emcn' -import { useSession } from '@/lib/auth/auth-client' -import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' -import { - getCanonicalScopesForProvider, - getProviderIdFromServiceId, - type OAuthProvider, -} from '@/lib/oauth' +import { consumeOAuthReturnContext } from '@/lib/credentials/client-state' +import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field' -import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' +import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal' import { getDependsOnFields } from '@/blocks/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry' import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' import { useCreateConnector } from '@/hooks/queries/kb/connectors' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' import type { SelectorKey } from '@/hooks/selectors/types' +import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' const SYNC_INTERVALS = [ { label: 'Every hour', value: 60 }, @@ -69,7 +65,6 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo const [searchTerm, setSearchTerm] = useState('') const { workspaceId } = useParams<{ workspaceId: string }>() - const { data: session } = useSession() const { mutate: createConnector, isPending: isCreating } = useCreateConnector() const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null @@ -82,10 +77,16 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo [connectorConfig] ) - const { data: credentials = [], isLoading: credentialsLoading } = useOAuthCredentials( - connectorProviderId ?? undefined, - { enabled: Boolean(connectorConfig) && !isApiKeyMode, workspaceId } - ) + const { + data: credentials = [], + isLoading: credentialsLoading, + refetch: refetchCredentials, + } = useOAuthCredentials(connectorProviderId ?? undefined, { + enabled: Boolean(connectorConfig) && !isApiKeyMode, + workspaceId, + }) + + useCredentialRefreshTriggers(refetchCredentials, connectorProviderId ?? '', workspaceId) const effectiveCredentialId = selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null) @@ -263,51 +264,9 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo ) } - const handleConnectNewAccount = useCallback(async () => { - if (!connectorConfig || !connectorProviderId || !workspaceId) return - - const userName = session?.user?.name - const integrationName = connectorConfig.name - const displayName = userName ? `${userName}'s ${integrationName}` : integrationName - - try { - const res = await fetch('/api/credentials/draft', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId, - providerId: connectorProviderId, - displayName, - }), - }) - if (!res.ok) { - setError('Failed to prepare credential. Please try again.') - return - } - } catch { - setError('Failed to prepare credential. Please try again.') - return - } - - writeOAuthReturnContext({ - origin: 'kb-connectors', - knowledgeBaseId, - displayName, - providerId: connectorProviderId, - preCount: credentials.length, - workspaceId, - requestedAt: Date.now(), - }) - + const handleConnectNewAccount = useCallback(() => { setShowOAuthModal(true) - }, [ - connectorConfig, - connectorProviderId, - workspaceId, - session?.user?.name, - knowledgeBaseId, - credentials.length, - ]) + }, []) const filteredEntries = useMemo(() => { const term = searchTerm.toLowerCase().trim() @@ -396,40 +355,40 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo ) : (
- {credentialsLoading ? ( -
- - Loading credentials... -
- ) : ( - ({ - label: cred.name || cred.provider, - value: cred.id, - icon: connectorConfig.icon, - }) - ), - { - label: 'Connect new account', - value: '__connect_new__', - icon: Plus, - onSelect: () => { - void handleConnectNewAccount() - }, + ({ + label: cred.name || cred.provider, + value: cred.id, + icon: connectorConfig.icon, + }) + ), + { + label: + credentials.length > 0 + ? `Connect another ${connectorConfig.name} account` + : `Connect ${connectorConfig.name} account`, + value: '__connect_new__', + icon: Plus, + onSelect: () => { + void handleConnectNewAccount() }, - ]} - value={effectiveCredentialId ?? undefined} - onChange={(value) => setSelectedCredentialId(value)} - placeholder={ - credentials.length === 0 - ? `No ${connectorConfig.name} accounts` - : 'Select account' - } - /> - )} + }, + ]} + value={effectiveCredentialId ?? undefined} + onChange={(value) => setSelectedCredentialId(value)} + onOpenChange={(isOpen) => { + if (isOpen) void refetchCredentials() + }} + placeholder={ + credentials.length === 0 + ? `No ${connectorConfig.name} accounts` + : 'Select account' + } + isLoading={credentialsLoading} + />
)} @@ -590,20 +549,23 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo )} - {connectorConfig && connectorConfig.auth.mode === 'oauth' && connectorProviderId && ( - { - consumeOAuthReturnContext() - setShowOAuthModal(false) - }} - provider={connectorProviderId} - toolName={connectorConfig.name} - requiredScopes={getCanonicalScopesForProvider(connectorProviderId)} - newScopes={[]} - serviceId={connectorConfig.auth.provider} - /> - )} + {showOAuthModal && + connectorConfig && + connectorConfig.auth.mode === 'oauth' && + connectorProviderId && ( + { + consumeOAuthReturnContext() + setShowOAuthModal(false) + }} + provider={connectorProviderId} + serviceId={connectorConfig.auth.provider} + workspaceId={workspaceId} + knowledgeBaseId={knowledgeBaseId} + credentialCount={credentials.length} + /> + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index f7eeac07c8b..37049a212d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -36,6 +36,7 @@ import { } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal' +import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { CONNECTOR_REGISTRY } from '@/connectors/registry' import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors' @@ -46,6 +47,7 @@ import { useUpdateConnector, } from '@/hooks/queries/kb/connectors' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' +import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' const logger = createLogger('ConnectorsSection') @@ -328,11 +330,16 @@ function ConnectorCard({ const requiredScopes = connectorDef?.auth.mode === 'oauth' ? (connectorDef.auth.requiredScopes ?? []) : [] - const { data: credentials } = useOAuthCredentials(providerId, { workspaceId }) + const { data: credentials, refetch: refetchCredentials } = useOAuthCredentials(providerId, { + workspaceId, + }) + + useCredentialRefreshTriggers(refetchCredentials, providerId ?? '', workspaceId) const missingScopes = useMemo(() => { if (!credentials || !connector.credentialId) return [] const credential = credentials.find((c) => c.id === connector.credentialId) + if (!credential) return [] return getMissingRequiredScopes(credential, requiredScopes) }, [credentials, connector.credentialId, requiredScopes]) @@ -484,15 +491,17 @@ function ConnectorCard({
)} - {showOAuthModal && serviceId && providerId && ( + {showOAuthModal && serviceId && providerId && !connector.credentialId && ( + { + consumeOAuthReturnContext() + setShowOAuthModal(false) + }} + provider={providerId as OAuthProvider} + serviceId={serviceId} + workspaceId={workspaceId} + knowledgeBaseId={knowledgeBaseId} + credentialCount={credentials?.length ?? 0} + /> + )} + + {showOAuthModal && serviceId && providerId && connector.credentialId && ( { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx index cc3a2306be8..c6574780423 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx @@ -14,6 +14,7 @@ import { ModalHeader, } from '@/components/emcn' import { client } from '@/lib/auth/auth-client' +import type { OAuthReturnContext } from '@/lib/credentials/client-state' import { writeOAuthReturnContext } from '@/lib/credentials/client-state' import { getCanonicalScopesForProvider, @@ -27,17 +28,22 @@ import { useCreateCredentialDraft } from '@/hooks/queries/credentials' const logger = createLogger('ConnectCredentialModal') -export interface ConnectCredentialModalProps { +interface ConnectCredentialModalBaseProps { isOpen: boolean onClose: () => void provider: OAuthProvider serviceId: string workspaceId: string - workflowId: string /** Number of existing credentials for this provider — used to detect a successful new connection. */ credentialCount: number } +export type ConnectCredentialModalProps = ConnectCredentialModalBaseProps & + ( + | { workflowId: string; knowledgeBaseId?: never } + | { workflowId?: never; knowledgeBaseId: string } + ) + export function ConnectCredentialModal({ isOpen, onClose, @@ -45,6 +51,7 @@ export function ConnectCredentialModal({ serviceId, workspaceId, workflowId, + knowledgeBaseId, credentialCount, }: ConnectCredentialModalProps) { const [displayName, setDisplayName] = useState('') @@ -97,15 +104,19 @@ export function ConnectCredentialModal({ try { await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmedName }) - writeOAuthReturnContext({ - origin: 'workflow', - workflowId, + const baseContext = { displayName: trimmedName, providerId, preCount: credentialCount, workspaceId, requestedAt: Date.now(), - }) + } + + const returnContext: OAuthReturnContext = knowledgeBaseId + ? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId } + : { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! } + + writeOAuthReturnContext(returnContext) if (providerId === 'trello') { window.location.href = '/api/auth/trello/authorize' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index e7915a6f907..26c897cf3d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -7,6 +7,7 @@ import { Button, Combobox } from '@/components/emcn/components' import { getSubscriptionAccessState } from '@/lib/billing/client' import { getEnv, isTruthy } from '@/lib/core/config/env' import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' +import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -357,7 +358,18 @@ export function CredentialSelector({