From cdc7513d2384b7b714468ac4213f63551a3c5150 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Wed, 13 May 2026 14:37:36 -0700 Subject: [PATCH 1/5] improvement(mothership): allow mship to send function execute timeout (#4581) * Improve mship fexecute * Fix --- .../lib/copilot/generated/tool-catalog-v1.ts | 5 + .../lib/copilot/generated/tool-schemas-v1.ts | 7 +- .../copilot/tool-executor/executor.test.ts | 104 ++++++++++++++++++ .../sim/lib/copilot/tool-executor/executor.ts | 22 +++- apps/sim/tools/function/types.ts | 5 + 5 files changed, 141 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 616244da295..fd373749aa3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1040,6 +1040,11 @@ export const FunctionExecute: ToolCatalogEntry = { description: 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, + timeout: { + type: 'number', + description: + 'Optional maximum execution time in seconds. If omitted, Copilot sends 10 seconds by default. Override when needed; capped at the default execution limit.', + }, }, required: ['code'], }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index bb329500012..9cd31211322 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4,7 +4,7 @@ export type JsonSchema = unknown -interface ToolRuntimeSchemaEntry { +export interface ToolRuntimeSchemaEntry { parameters?: JsonSchema resultSchema?: JsonSchema } @@ -863,6 +863,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, + timeout: { + type: 'number', + description: + 'Optional maximum execution time in seconds. If omitted, Copilot sends 10 seconds by default. Override when needed; capped at the default execution limit.', + }, }, required: ['code'], }, diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 4ae97dd45c7..a0cd2eec358 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' const { isKnownTool, isSimExecuted } = vi.hoisted(() => ({ isKnownTool: vi.fn(), @@ -58,4 +59,107 @@ describe('copilot tool executor fallback', () => { ) expect(result).toEqual({ success: true, output: { emails: [] } }) }) + + it('converts function_execute timeout from seconds to milliseconds for copilot calls', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1', timeout: 7 }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: 7000, + _context: expect.objectContaining({ + copilotToolExecution: true, + }), + }), + false + ) + }) + + it('defaults copilot function_execute timeout to 10 seconds when omitted', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: 10_000, + }), + false + ) + }) + + it('defaults copilot function_execute timeout to 10 seconds when invalid', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1', timeout: 0 }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: 10_000, + }), + false + ) + }) + + it('does not let copilot function_execute timeout exceed the default execution limit', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1', timeout: 10_000 }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: DEFAULT_EXECUTION_TIMEOUT_MS, + }), + false + ) + }) }) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 7658a25e70e..869228970bf 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' import { isKnownTool, isSimExecuted } from './router' import type { @@ -10,6 +11,9 @@ import type { } from './types' const logger = createLogger('ToolExecutor') +const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' +const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10 +const MILLISECONDS_PER_SECOND = 1000 const handlerRegistry = new Map() @@ -38,7 +42,7 @@ export async function executeTool( ): Promise { const canUseRegisteredHandler = isKnownTool(toolId) && isSimExecuted(toolId) if (!canUseRegisteredHandler) { - const appParams = buildAppToolParams(params, context) + const appParams = buildAppToolParams(toolId, params, context) return executeAppTool(toolId, appParams, false) } @@ -95,11 +99,27 @@ async function executeToolBatch( } function buildAppToolParams( + toolId: string, params: Record, context: ToolExecutionContext ): Record { const result = { ...params } + if (toolId === FUNCTION_EXECUTE_TOOL_ID && context.copilotToolExecution) { + const rawTimeoutSeconds = + result.timeout === undefined || result.timeout === null + ? DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + : Number(result.timeout) + const timeoutSeconds = + Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 + ? rawTimeoutSeconds + : DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + result.timeout = Math.min( + Math.ceil(timeoutSeconds * MILLISECONDS_PER_SECOND), + DEFAULT_EXECUTION_TIMEOUT_MS + ) + } + if (result.credentialId && !result.credential && !result.oauthCredential) { result.credential = result.credentialId } diff --git a/apps/sim/tools/function/types.ts b/apps/sim/tools/function/types.ts index d44d509174e..decb3978871 100644 --- a/apps/sim/tools/function/types.ts +++ b/apps/sim/tools/function/types.ts @@ -7,6 +7,10 @@ export interface CodeExecutionInput { sourceCode?: string language?: CodeLanguage useLocalVM?: boolean + /** + * Workflow Function blocks pass milliseconds. Copilot/Mothership tool calls pass seconds + * and are converted at the request boundary. + */ timeout?: number memoryLimit?: number outputPath?: string @@ -28,6 +32,7 @@ export interface CodeExecutionInput { allowLargeValueWorkflowScope?: boolean userId?: string workspaceId?: string + copilotToolExecution?: boolean } isCustomTool?: boolean _sandboxFiles?: Array<{ path: string; content: string }> From 1ed3a4ec6d4bd183cc06f7f83754de17f5217149 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 13 May 2026 14:46:04 -0700 Subject: [PATCH 2/5] feat(mothership): pin tasks to keep them at the top of the sidebar (#4582) * feat(mothership): pin tasks to keep them at the top of the sidebar * fix(sidebar): address PR review feedback for pin tasks * fix(posthog): register task_pinned and task_unpinned events * fix(tasks): insert new optimistic tasks below pinned partition --- .../api/mothership/chats/[chatId]/route.ts | 15 +- apps/sim/app/api/mothership/chats/route.ts | 3 +- .../components/context-menu/context-menu.tsx | 23 +- .../w/components/sidebar/sidebar.tsx | 60 +- apps/sim/hooks/queries/tasks.test.ts | 3 + apps/sim/hooks/queries/tasks.ts | 68 +- .../sim/lib/api/contracts/mothership-tasks.ts | 11 +- apps/sim/lib/posthog/events.ts | 8 + .../0207_colorful_secret_warriors.sql | 1 + .../db/migrations/meta/0207_snapshot.json | 15873 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 1 + 12 files changed, 16043 insertions(+), 30 deletions(-) create mode 100644 packages/db/migrations/0207_colorful_secret_warriors.sql create mode 100644 packages/db/migrations/meta/0207_snapshot.json diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index b3a86bc8a2e..91c88712cfd 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -142,7 +142,7 @@ export const PATCH = withRouteHandler( const parsed = await parseRequest(updateMothershipChatContract, request, context) if (!parsed.success) return parsed.response const { chatId } = parsed.data.params - const { title, isUnread } = parsed.data.body + const { title, isUnread, pinned } = parsed.data.body const updates: Record = {} @@ -157,6 +157,9 @@ export const PATCH = withRouteHandler( if (isUnread !== undefined) { updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())` } + if (pinned !== undefined) { + updates.pinned = pinned + } const [updatedChat] = await db .update(copilotChats) @@ -203,6 +206,16 @@ export const PATCH = withRouteHandler( } ) } + if (pinned !== undefined) { + captureServerEvent( + userId, + pinned ? 'task_pinned' : 'task_unpinned', + { workspace_id: updatedChat.workspaceId }, + { + groups: { workspace: updatedChat.workspaceId }, + } + ) + } } return NextResponse.json({ success: true }) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index b0a068fabc1..f6d2d9eae35 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -45,6 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { updatedAt: copilotChats.updatedAt, activeStreamId: copilotChats.conversationId, lastSeenAt: copilotChats.lastSeenAt, + pinned: copilotChats.pinned, }) .from(copilotChats) .where( @@ -54,7 +55,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { eq(copilotChats.type, 'mothership') ) ) - .orderBy(desc(copilotChats.updatedAt)) + .orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt)) const streamMarkers = await reconcileChatStreamMarkers( chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })), diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index ab876adca8b..5c516adc01e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -1,6 +1,7 @@ 'use client' import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Pin, PinOff } from 'lucide-react' import { Button, DropdownMenu, @@ -234,6 +235,7 @@ interface ContextMenuProps { onOpenInNewTab?: () => void onMarkAsRead?: () => void onMarkAsUnread?: () => void + onTogglePin?: () => void onRename?: () => void onCreate?: () => void onCreateFolder?: () => void @@ -245,6 +247,8 @@ interface ContextMenuProps { showOpenInNewTab?: boolean showMarkAsRead?: boolean showMarkAsUnread?: boolean + showPin?: boolean + isPinned?: boolean showRename?: boolean showCreate?: boolean showCreateFolder?: boolean @@ -288,6 +292,7 @@ export function ContextMenu({ onOpenInNewTab, onMarkAsRead, onMarkAsUnread, + onTogglePin, onRename, onCreate, onCreateFolder, @@ -299,6 +304,8 @@ export function ContextMenu({ showOpenInNewTab = false, showMarkAsRead = false, showMarkAsUnread = false, + showPin = false, + isPinned = false, showRename = true, showCreate = false, showCreateFolder = false, @@ -375,7 +382,10 @@ export function ContextMenu({ }, []) const hasNavigationSection = showOpenInNewTab && onOpenInNewTab - const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread) + const hasStatusSection = + (showMarkAsRead && onMarkAsRead) || + (showMarkAsUnread && onMarkAsUnread) || + (showPin && onTogglePin) const hasEditSection = (showRename && onRename) || (showCreate && onCreate) || @@ -447,6 +457,17 @@ export function ContextMenu({ Mark as unread )} + {showPin && onTogglePin && ( + { + onTogglePin() + onClose() + }} + > + {isPinned ? : } + {isPinned ? 'Unpin' : 'Pin'} + + )} {hasStatusSection && (hasEditSection || hasCopySection) && } {showRename && onRename && ( 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 29d403eb5ac..f7c81bafb66 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Compass, MoreHorizontal } from 'lucide-react' +import { Compass, MoreHorizontal, Pin } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useParams, usePathname, useRouter } from 'next/navigation' @@ -91,6 +91,7 @@ import { useMarkTaskRead, useMarkTaskUnread, useRenameTask, + useSetTaskPinned, useTasks, } from '@/hooks/queries/tasks' import { useUpdateWorkflow } from '@/hooks/queries/workflows' @@ -144,6 +145,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ isSelected, isActive, isUnread, + isPinned, isMenuOpen, showCollapsedTooltips, onMultiSelectClick, @@ -156,6 +158,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ isSelected: boolean isActive: boolean isUnread: boolean + isPinned: boolean isMenuOpen: boolean showCollapsedTooltips: boolean onMultiSelectClick: (taskId: string, shiftKey: boolean) => void @@ -219,6 +222,9 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ {!isActive && isUnread && !isCurrentRoute && !isMenuOpen && ( )} + {!isActive && !isUnread && isPinned && !isCurrentRoute && !isMenuOpen && ( + + )}