From 0c79a18ff5045acd21c2fb30b49a4336173e81b3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 6 Aug 2026 10:45:46 -0700 Subject: [PATCH] Fix lint --- .../sim/app/api/copilot/confirm/route.test.ts | 40 +++- apps/sim/app/api/copilot/confirm/route.ts | 20 +- .../[id]/execute/route.async.test.ts | 96 +++++++++ .../app/api/workflows/[id]/execute/route.ts | 52 ++++- .../message-content/message-content.test.ts | 17 ++ .../async-execution-correlation.test.ts | 26 +++ apps/sim/background/workflow-execution.ts | 8 + .../lib/copilot/generated/tool-catalog-v1.ts | 195 +----------------- .../lib/copilot/generated/tool-schemas-v1.ts | 161 +-------------- .../copilot/request/handlers/handlers.test.ts | 42 ++++ apps/sim/lib/copilot/request/handlers/tool.ts | 11 +- .../sim/lib/copilot/request/handlers/types.ts | 14 +- .../lib/copilot/request/tools/client.test.ts | 34 +++ apps/sim/lib/copilot/request/tools/client.ts | 22 +- .../tools/client/run-tool-execution.test.ts | 123 +++++++++++ .../tools/client/run-tool-execution.ts | 145 ++++++++++++- .../lib/copilot/tools/handlers/param-types.ts | 2 + apps/sim/lib/copilot/tools/workflow-tools.ts | 33 ++- apps/sim/lib/execution/preprocessing.test.ts | 30 ++- apps/sim/lib/execution/preprocessing.ts | 3 + .../lib/webhooks/providers/zoho-desk.test.ts | 9 - .../src/mocks/execution-preprocessing.mock.ts | 1 + 22 files changed, 717 insertions(+), 367 deletions(-) diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index a97acc3a85f..71c962e78c6 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -459,7 +459,7 @@ describe('Copilot Confirm API Route', () => { getAsyncToolCall.mockResolvedValue({ ...existingRow, toolName: 'run_workflow', - args: { workflowId: 'workflow-1' }, + args: { workflowId: 'workflow-1', async: true }, status: 'running', claimedBy: null, }) @@ -469,6 +469,7 @@ describe('Copilot Confirm API Route', () => { toolCallId: 'tool-call-123', status: 'error', message: 'untrusted client detail', + data: { code: 'ASYNC_WORKFLOW_DEPLOYMENT_STALE' }, }) ) @@ -477,14 +478,47 @@ describe('Copilot Confirm API Route', () => { expect(completeAsyncToolCall).toHaveBeenCalledWith({ toolCallId: 'tool-call-123', status: 'failed', - result: { success: false, workflowId: 'workflow-1' }, - error: 'Workflow execution failed.', + result: { + success: false, + workflowId: 'workflow-1', + code: 'ASYNC_WORKFLOW_DEPLOYMENT_STALE', + error: 'Async execution requires the current workflow to match its deployed version', + }, + error: 'Async execution requires the current workflow to match its deployed version', }) expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain( 'untrusted client detail' ) }) + it('discards an unknown async workflow preflight failure', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1', async: true }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'untrusted client detail', + data: { code: 'UNTRUSTED_CLIENT_CODE' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { success: false, workflowId: 'workflow-1' }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted') + }) + it('downgrades an unverifiable success from a stale client to a structural failure', async () => { getAsyncToolCall.mockResolvedValue({ ...existingRow, diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index 07ca1052dc2..96dbdcccae7 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -2,6 +2,7 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isPlainRecord } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { copilotConfirmContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' @@ -38,7 +39,9 @@ import { sealClientToolCompletion, } from '@/lib/copilot/request/tools/client-completion-seal.server' import { + type AsyncWorkflowDeploymentError, createStructuralWorkflowToolCompletionData, + getAsyncWorkflowDeploymentError, getWorkflowToolCompletionExecutionId, getWorkflowToolCompletionMessage, getWorkflowToolConfirmationStatus, @@ -278,6 +281,7 @@ export const POST = withRouteHandler((req: NextRequest) => { let effectiveStatus = status let executionId = submittedExecutionId + let deploymentError: AsyncWorkflowDeploymentError | undefined if (isWorkflowTool) { const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy) @@ -329,16 +333,28 @@ export const POST = withRouteHandler((req: NextRequest) => { } else { executionId = undefined } + + if ( + effectiveStatus === ASYNC_TOOL_CONFIRMATION_STATUS.error && + executionId === undefined && + existing.toolName === 'run_workflow' && + isPlainRecord(existing.args) && + existing.args.async === true + ) { + deploymentError = getAsyncWorkflowDeploymentError(data) + } } span.setAttribute(TraceAttr.ToolConfirmationStatus, effectiveStatus) const projected = isWorkflowTool ? { - message: getWorkflowToolCompletionMessage(effectiveStatus), + message: + deploymentError?.message ?? getWorkflowToolCompletionMessage(effectiveStatus), data: createStructuralWorkflowToolCompletionData( effectiveStatus, workflowId, - executionId + executionId, + deploymentError ), } : { 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 d80d2f2aa31..b10ba5c4480 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 @@ -29,6 +29,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types' import { getRemainingExecutionMs } from '@/lib/core/execution-limits' import { INTERNAL_EXECUTION_DEADLINE_HEADER } from '@/lib/execution/execution-deadline-header' +import { WORKFLOW_NOT_DEPLOYED_CODE } from '@/lib/execution/preprocessing' import { PRIVATE_SECRET_PROVENANCE_BUNDLE_V1, PRIVATE_SECRET_PROVENANCE_FIELD, @@ -39,6 +40,7 @@ const { mockAssertBillingAttributionSnapshot, mockClaimExecutionId, mockClaimWorkflowToolExecution, + mockCheckNeedsRedeployment, mockEnqueue, mockExecuteWorkflowJob, mockExecuteWorkflowCore, @@ -67,6 +69,7 @@ const { }), mockClaimExecutionId: vi.fn(), mockClaimWorkflowToolExecution: vi.fn(), + mockCheckNeedsRedeployment: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('job-123'), mockExecuteWorkflowJob: vi.fn(), mockExecuteWorkflowCore: vi.fn(), @@ -118,6 +121,10 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) +vi.mock('@/app/api/workflows/utils', () => ({ + checkNeedsRedeployment: mockCheckNeedsRedeployment, +})) + vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) vi.mock('@/lib/workflows/executor/execution-core', () => ({ @@ -415,6 +422,7 @@ describe('workflow execute async route', () => { toolCallId: 'copilot-tool-1', claimedBy: 'workflow:execution-123', }) + mockCheckNeedsRedeployment.mockResolvedValue(false) mockHasDurableExecutionOwner.mockResolvedValue(false) mockGetAsyncToolCall.mockReset().mockResolvedValue({ toolCallId: 'copilot-tool-1', @@ -875,6 +883,94 @@ describe('workflow execute async route', () => { expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() }) + it('queues a bound Copilot workflow execution asynchronously', async () => { + const request = createBoundCopilotExecutionRequest({ + stream: false, + triggerBlockId: 'trigger-async', + }) + request.headers.set('X-Execution-Mode', 'async') + + const response = await POST(request, { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(202) + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ checkDeployment: true, executionType: 'async' }) + ) + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).toHaveBeenCalledWith({ + executionId: 'execution-123', + requestId: 'req-12345678', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'copilot-tool-1', + }) + expect(mockEnqueue).toHaveBeenCalledWith( + 'workflow-execution', + expect.objectContaining({ + executionId: 'execution-123', + triggerBlockId: 'trigger-async', + correlation: expect.objectContaining({ copilotToolCallId: 'copilot-tool-1' }), + }), + expect.any(Object) + ) + }) + + it('rejects a bound async run when the deployed workflow is stale', async () => { + mockCheckNeedsRedeployment.mockResolvedValueOnce(true) + const request = createBoundCopilotExecutionRequest({ stream: false }) + request.headers.set('X-Execution-Mode', 'async') + + const response = await POST(request, { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: 'Async execution requires the current workflow to match its deployed version', + code: 'ASYNC_WORKFLOW_DEPLOYMENT_STALE', + }) + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-123') + expect(mockReleaseWorkflowToolExecutionClaim).toHaveBeenCalledWith( + 'copilot-tool-1', + 'execution-123' + ) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + + it('rejects a bound async run when the workflow has not been deployed', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { + message: 'Workflow is not deployed', + statusCode: 403, + code: WORKFLOW_NOT_DEPLOYED_CODE, + }, + }) + const request = createBoundCopilotExecutionRequest({ stream: false }) + request.headers.set('X-Execution-Mode', 'async') + + const response = await POST(request, { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Async execution requires the workflow to be deployed first', + code: 'ASYNC_WORKFLOW_DEPLOYMENT_MISSING', + }) + expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-123') + expect(mockReleaseWorkflowToolExecutionClaim).toHaveBeenCalledWith( + 'copilot-tool-1', + 'execution-123' + ) + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockCheckNeedsRedeployment).not.toHaveBeenCalled() + }) + it.each([ [ 'cancelled', diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 91a67298ed4..308b786dd5d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -28,7 +28,11 @@ import { releaseWorkflowToolExecutionClaim, } from '@/lib/copilot/async-runs/repository' import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' -import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' +import { + ASYNC_WORKFLOW_DEPLOYMENT_ERRORS, + isWorkflowToolName, + resolveWorkflowToolTargetId, +} from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' @@ -74,7 +78,11 @@ import { } from '@/lib/execution/manual-cancellation' import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer' -import { type PreprocessExecutionSuccess, preprocessExecution } from '@/lib/execution/preprocessing' +import { + type PreprocessExecutionSuccess, + preprocessExecution, + WORKFLOW_NOT_DEPLOYED_CODE, +} from '@/lib/execution/preprocessing' import { PRIVATE_SECRET_PROVENANCE_FIELD, PRIVATE_TOOL_METADATA_RESPONSE_HEADER, @@ -133,6 +141,7 @@ import { } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' +import { checkNeedsRedeployment } from '@/app/api/workflows/utils' import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { @@ -380,7 +389,9 @@ type AsyncExecutionParams = { workspaceId: string input: any triggerType: CoreTriggerType + triggerBlockId?: string executionId: string + copilotToolCallId?: string callChain?: string[] executionTimeoutMs: number trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 @@ -436,7 +447,9 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise> try { jobQueue = await getJobQueue() @@ -866,8 +896,7 @@ async function handleExecutePost( (auth.authType !== AuthType.SESSION || !isClientSession || triggerType !== 'copilot' || - !enableSSE || - isAsyncMode) + (!isAsyncMode && !enableSSE)) ) { return NextResponse.json( { error: 'Copilot tool execution binding is invalid for this request' }, @@ -1006,7 +1035,9 @@ async function handleExecutePost( // Public API callers always execute the deployed state, never the draft. const shouldUseDraftState = isPublicApiAccess ? false - : (useDraftState ?? auth.authType === AuthType.SESSION) + : isAsyncMode + ? false + : (useDraftState ?? auth.authType === AuthType.SESSION) const requiresWriteExecutionAccess = Boolean( useDraftState || workflowStateOverride || rawRunFromBlock ) @@ -1020,7 +1051,6 @@ async function handleExecutePost( (body.useDraftState !== undefined || body.workflowStateOverride !== undefined || body.runFromBlock !== undefined || - body.triggerBlockId !== undefined || body.stopAfterBlockId !== undefined || body.selectedOutputs?.length || body.includeFileBase64 !== undefined || @@ -1267,6 +1297,14 @@ async function handleExecutePost( if (!preprocessResult.success) { const preprocessError = preprocessResult.error + if (isAsyncMode && copilotToolCallId && preprocessError.code === WORKFLOW_NOT_DEPLOYED_CODE) { + const deploymentError = ASYNC_WORKFLOW_DEPLOYMENT_ERRORS.missing + await releaseExecutionSlot(executionId) + return NextResponse.json( + { error: deploymentError.message, code: deploymentError.code }, + { status: preprocessError.statusCode } + ) + } return NextResponse.json( { error: preprocessError.message }, { status: preprocessError.statusCode } @@ -1317,7 +1355,9 @@ async function handleExecutePost( workspaceId, input, triggerType: loggingTriggerType, + triggerBlockId, executionId, + copilotToolCallId, callChain, executionTimeoutMs: preprocessResult.executionTimeout.async, trustedInitialResolvedSecretTraceProvenance, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 60c7a5fbce9..fb88e6780a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -452,6 +452,23 @@ describe('completed tool titles', () => { ) }) + it('renders an accepted async workflow launch in past tense', () => { + expect( + firstToolTitle([ + { + type: 'tool_call', + toolCall: { + id: 'async-workflow', + name: 'run_workflow', + status: 'success', + params: { async: true }, + }, + timestamp: 1, + }, + ]) + ).toBe('Ran workflow') + }) + it('renders the completed deployment action and deployment type', () => { expect( firstToolTitle([ diff --git a/apps/sim/background/async-execution-correlation.test.ts b/apps/sim/background/async-execution-correlation.test.ts index e3eeeefe66c..763a6c45e2e 100644 --- a/apps/sim/background/async-execution-correlation.test.ts +++ b/apps/sim/background/async-execution-correlation.test.ts @@ -32,6 +32,32 @@ describe('async execution correlation fallbacks', () => { }) }) + it('preserves a trusted Copilot workflow tool binding', () => { + const correlation = buildWorkflowCorrelation({ + workflowId: 'workflow-1', + userId: 'user-1', + triggerType: 'copilot', + executionId: 'execution-copilot', + correlation: { + executionId: 'execution-copilot', + requestId: 'request-copilot', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'tool-call-1', + }, + }) + + expect(correlation).toEqual({ + executionId: 'execution-copilot', + requestId: 'request-copilot', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'tool-call-1', + }) + }) + it('falls back for legacy schedule payloads missing preassigned request id', () => { const correlation = buildScheduleCorrelation({ scheduleId: 'schedule-1', diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index c76f8b3e7b3..c29ad7601f7 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -49,6 +49,9 @@ export function buildWorkflowCorrelation( requestId, source: 'workflow', workflowId: payload.workflowId, + ...(payload.correlation?.copilotToolCallId + ? { copilotToolCallId: payload.correlation.copilotToolCallId } + : {}), triggerType: payload.triggerType || payload.correlation?.triggerType || 'api', } } @@ -60,6 +63,7 @@ export type WorkflowExecutionPayload = { workspaceId: string input?: any triggerType?: CoreTriggerType + triggerBlockId?: string executionId?: string requestId?: string correlation?: AsyncExecutionCorrelation @@ -134,6 +138,9 @@ export async function executeWorkflowJob( const triggerType = (correlation.triggerType || 'api') as CoreTriggerType const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId) + if (correlation.copilotToolCallId) { + loggingSession.setTrustedExecutionCorrelation(correlation) + } loggingSession.setExecutionDeadlineAt(getExecutionDeadlineAt(timeoutController.signal)) try { @@ -182,6 +189,7 @@ export async function executeWorkflowJob( sessionUserId: undefined, workflowUserId: workflow.userId, triggerType: payload.triggerType || 'api', + triggerBlockId: payload.triggerBlockId, useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index a5f87e5d429..e67b7fd1761 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -33,7 +33,6 @@ export interface ToolCatalogEntry { | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' - | 'complete_scheduled_task' | 'cp' | 'crawl_website' | 'create_file' @@ -63,7 +62,6 @@ export interface ToolCatalogEntry { | 'get_deployment_log' | 'get_page_contents' | 'get_platform_actions' - | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -80,7 +78,6 @@ export interface ToolCatalogEntry { | 'manage_custom_tool' | 'manage_mcp_tool' | 'manage_sandbox' - | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' | 'media' @@ -103,7 +100,6 @@ export interface ToolCatalogEntry { | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' - | 'scheduled_task' | 'scrape_page' | 'search' | 'search_documentation' @@ -119,7 +115,6 @@ export interface ToolCatalogEntry { | 'table' | 'terminal' | 'update_deployment_version' - | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' | 'wait' @@ -154,7 +149,6 @@ export interface ToolCatalogEntry { | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' - | 'complete_scheduled_task' | 'cp' | 'crawl_website' | 'create_file' @@ -184,7 +178,6 @@ export interface ToolCatalogEntry { | 'get_deployment_log' | 'get_page_contents' | 'get_platform_actions' - | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -201,7 +194,6 @@ export interface ToolCatalogEntry { | 'manage_custom_tool' | 'manage_mcp_tool' | 'manage_sandbox' - | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' | 'media' @@ -224,7 +216,6 @@ export interface ToolCatalogEntry { | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' - | 'scheduled_task' | 'scrape_page' | 'search' | 'search_documentation' @@ -240,7 +231,6 @@ export interface ToolCatalogEntry { | 'table' | 'terminal' | 'update_deployment_version' - | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' | 'wait' @@ -260,7 +250,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'media' | 'run' - | 'scheduled_task' | 'search' | 'table' | 'workflow' @@ -1249,20 +1238,6 @@ export const CheckDeploymentStatus: ToolCatalogEntry = { }, } -export const CompleteScheduledTask: ToolCatalogEntry = { - id: 'complete_scheduled_task', - name: 'complete_scheduled_task', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - jobId: { type: 'string', description: 'The ID of the scheduled task to mark as completed.' }, - }, - required: ['jobId'], - }, -} - export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -3037,26 +3012,6 @@ export const GetPlatformActions: ToolCatalogEntry = { parameters: { type: 'object', properties: {} }, } -export const GetScheduledTaskLogs: ToolCatalogEntry = { - id: 'get_scheduled_task_logs', - name: 'get_scheduled_task_logs', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Optional execution ID for a specific run.' }, - includeDetails: { - type: 'boolean', - description: 'Include tool calls, outputs, and cost details.', - }, - jobId: { type: 'string', description: 'The scheduled task (schedule) ID to get logs for.' }, - limit: { type: 'number', description: 'Max number of entries (default: 3, max: 5)' }, - }, - required: ['jobId'], - }, -} - export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -3508,7 +3463,7 @@ export const ManageCustomTool: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -3598,7 +3553,7 @@ export const ManageMcpTool: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { @@ -3665,80 +3620,6 @@ export const ManageSandbox: ToolCatalogEntry = { requiredPermission: 'admin', } -export const ManageScheduledTask: ToolCatalogEntry = { - id: 'manage_scheduled_task', - name: 'manage_scheduled_task', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: - 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', - properties: { - cron: { - type: 'string', - description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", - }, - jobId: { type: 'string', description: 'Scheduled task ID (required for get, update)' }, - jobIds: { - type: 'array', - description: 'Array of scheduled task IDs (for batch delete)', - items: { type: 'string' }, - }, - lifecycle: { - type: 'string', - description: - "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", - enum: ['persistent', 'until_complete'], - }, - maxRuns: { - type: 'integer', - description: 'Max executions before auto-completing. Safety limit.', - }, - prompt: { - type: 'string', - description: 'The prompt to execute when the scheduled task fires', - }, - status: { - type: 'string', - description: 'Scheduled task status: active, paused', - enum: ['active', 'paused'], - }, - successCondition: { - type: 'string', - description: - 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', - }, - time: { - type: 'string', - description: - "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", - }, - timezone: { - type: 'string', - description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.', - }, - title: { - type: 'string', - description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", - }, - }, - }, - operation: { - type: 'string', - description: - 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', - enum: ['create', 'list', 'get', 'update', 'delete'], - }, - }, - required: ['operation'], - }, -} - export const ManageSkill: ToolCatalogEntry = { id: 'manage_skill', name: 'manage_skill', @@ -3763,7 +3644,7 @@ export const ManageSkill: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -3943,7 +3824,7 @@ export const OpenResource: ToolCatalogEntry = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, required: ['type'], @@ -4103,7 +3984,7 @@ export const QueryUserTable: ToolCatalogEntry = { filter: { type: 'object', description: - 'Predicate filter object for query_rows. A single condition is {field, op, value}; use {"all":[...]} (AND) or {"any":[...]} (OR) for multiple or nested conditions. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"field":"status","op":"eq","value":"active"}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"field":"name","op":"ilike","value":"*jo*"}.', + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', @@ -4503,6 +4384,11 @@ export const RunWorkflow: ToolCatalogEntry = { parameters: { type: 'object', properties: { + async: { + type: 'boolean', + description: + 'Queue the deployed workflow and return its execution ID immediately. Default: false. Set true only when explicitly asked for a background run, or when the three most recent completed runs each exceeded 30 minutes. Fails if the current workflow differs from its deployed version. Missing history, complexity, or one slow run never justify async; check completion later with query_logs.', + }, inputFromExecutionId: { type: 'string', description: @@ -4588,22 +4474,6 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { requiresApproval: true, } -export const ScheduledTask: ToolCatalogEntry = { - id: 'scheduled_task', - name: 'scheduled_task', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - request: { description: 'What scheduled task action is needed.', type: 'string' }, - }, - required: ['request'], - type: 'object', - }, - subagentId: 'scheduled_task', - internal: true, -} - export const ScrapePage: ToolCatalogEntry = { id: 'scrape_page', name: 'scrape_page', @@ -5148,25 +5018,6 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = { requiredPermission: 'write', } -export const UpdateScheduledTaskHistory: ToolCatalogEntry = { - id: 'update_scheduled_task_history', - name: 'update_scheduled_task_history', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - jobId: { type: 'string', description: 'The scheduled task ID.' }, - summary: { - type: 'string', - description: - "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').", - }, - }, - required: ['jobId', 'summary'], - }, -} - export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { id: 'update_workspace_mcp_server', name: 'update_workspace_mcp_server', @@ -5263,7 +5114,7 @@ export const UserTable: ToolCatalogEntry = { filter: { type: 'object', description: - 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A single condition is {field, op, value}; use {"all":[...]} (AND) or {"any":[...]} (OR) for multiple or nested conditions. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"field":"status","op":"eq","value":"active"}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"field":"name","op":"ilike","value":"*jo*"}; {"field":"slack_user_id","op":"in","value":["U1","U2"]}.', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -5841,25 +5692,6 @@ export const ManageSandboxOperationValues = [ ManageSandboxOperation.list, ] as const -export const ManageScheduledTaskOperation = { - create: 'create', - list: 'list', - get: 'get', - update: 'update', - delete: 'delete', -} as const - -export type ManageScheduledTaskOperation = - (typeof ManageScheduledTaskOperation)[keyof typeof ManageScheduledTaskOperation] - -export const ManageScheduledTaskOperationValues = [ - ManageScheduledTaskOperation.create, - ManageScheduledTaskOperation.list, - ManageScheduledTaskOperation.get, - ManageScheduledTaskOperation.update, - ManageScheduledTaskOperation.delete, -] as const - export const ManageSkillOperation = { add: 'add', edit: 'edit', @@ -6063,7 +5895,6 @@ export const TOOL_CATALOG: Record = { [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, [CheckDeploymentStatus.id]: CheckDeploymentStatus, - [CompleteScheduledTask.id]: CompleteScheduledTask, [Cp.id]: Cp, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, @@ -6093,7 +5924,6 @@ export const TOOL_CATALOG: Record = { [GetDeploymentLog.id]: GetDeploymentLog, [GetPageContents.id]: GetPageContents, [GetPlatformActions.id]: GetPlatformActions, - [GetScheduledTaskLogs.id]: GetScheduledTaskLogs, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, @@ -6110,7 +5940,6 @@ export const TOOL_CATALOG: Record = { [ManageCustomTool.id]: ManageCustomTool, [ManageMcpTool.id]: ManageMcpTool, [ManageSandbox.id]: ManageSandbox, - [ManageScheduledTask.id]: ManageScheduledTask, [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, @@ -6133,7 +5962,6 @@ export const TOOL_CATALOG: Record = { [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, - [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, [Search.id]: Search, [SearchDocumentation.id]: SearchDocumentation, @@ -6149,7 +5977,6 @@ export const TOOL_CATALOG: Record = { [Table.id]: Table, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, - [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, [Wait.id]: Wait, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 6fcfe7ad3ea..81188db33ab 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1106,19 +1106,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { - parameters: { - type: 'object', - properties: { - jobId: { - type: 'string', - description: 'The ID of the scheduled task to mark as completed.', - }, - }, - required: ['jobId'], - }, - resultSchema: undefined, - }, cp: { parameters: { type: 'object', @@ -2926,31 +2913,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_scheduled_task_logs: { - parameters: { - type: 'object', - properties: { - executionId: { - type: 'string', - description: 'Optional execution ID for a specific run.', - }, - includeDetails: { - type: 'boolean', - description: 'Include tool calls, outputs, and cost details.', - }, - jobId: { - type: 'string', - description: 'The scheduled task (schedule) ID to get logs for.', - }, - limit: { - type: 'number', - description: 'Max number of entries (default: 3, max: 5)', - }, - }, - required: ['jobId'], - }, - resultSchema: undefined, - }, get_workflow_data: { parameters: { type: 'object', @@ -3381,7 +3343,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -3488,7 +3450,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { @@ -3555,81 +3517,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: - 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', - properties: { - cron: { - type: 'string', - description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", - }, - jobId: { - type: 'string', - description: 'Scheduled task ID (required for get, update)', - }, - jobIds: { - type: 'array', - description: 'Array of scheduled task IDs (for batch delete)', - items: { - type: 'string', - }, - }, - lifecycle: { - type: 'string', - description: - "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", - enum: ['persistent', 'until_complete'], - }, - maxRuns: { - type: 'integer', - description: 'Max executions before auto-completing. Safety limit.', - }, - prompt: { - type: 'string', - description: 'The prompt to execute when the scheduled task fires', - }, - status: { - type: 'string', - description: 'Scheduled task status: active, paused', - enum: ['active', 'paused'], - }, - successCondition: { - type: 'string', - description: - 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', - }, - time: { - type: 'string', - description: - "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", - }, - timezone: { - type: 'string', - description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.', - }, - title: { - type: 'string', - description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", - }, - }, - }, - operation: { - type: 'string', - description: - 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', - enum: ['create', 'list', 'get', 'update', 'delete'], - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, manage_skill: { parameters: { type: 'object', @@ -3650,7 +3537,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -3805,7 +3692,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, required: ['type'], @@ -3962,7 +3849,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'Predicate filter object for query_rows. A single condition is {field, op, value}; use {"all":[...]} (AND) or {"any":[...]} (OR) for multiple or nested conditions. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"field":"status","op":"eq","value":"active"}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"field":"name","op":"ilike","value":"*jo*"}.', + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', @@ -4366,6 +4253,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + async: { + type: 'boolean', + description: + 'Queue the deployed workflow and return its execution ID immediately. Default: false. Set true only when explicitly asked for a background run, or when the three most recent completed runs each exceeded 30 minutes. Fails if the current workflow differs from its deployed version. Missing history, complexity, or one slow run never justify async; check completion later with query_logs.', + }, inputFromExecutionId: { type: 'string', description: @@ -4443,19 +4335,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { - parameters: { - properties: { - request: { - description: 'What scheduled task action is needed.', - type: 'string', - }, - }, - required: ['request'], - type: 'object', - }, - resultSchema: undefined, - }, scrape_page: { parameters: { type: 'object', @@ -4980,24 +4859,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { - parameters: { - type: 'object', - properties: { - jobId: { - type: 'string', - description: 'The scheduled task ID.', - }, - summary: { - type: 'string', - description: - "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').", - }, - }, - required: ['jobId', 'summary'], - }, - resultSchema: undefined, - }, update_workspace_mcp_server: { parameters: { type: 'object', @@ -5103,7 +4964,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A single condition is {field, op, value}; use {"all":[...]} (AND) or {"any":[...]} (OR) for multiple or nested conditions. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"field":"status","op":"eq","value":"active"}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"field":"name","op":"ilike","value":"*jo*"}; {"field":"slack_user_id","op":"in","value":["U1","U2"]}.', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 0c44d9115e6..b98cbb79438 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -601,6 +601,48 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('settles an explicitly async workflow launch as successful', async () => { + waitForWorkflowToolCompletion.mockResolvedValueOnce({ + status: 'background', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-async-workflow', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1', async: true }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: true, timeout: 1000 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(context.toolCalls.get('tool-async-workflow')?.status).toBe( + MothershipStreamV1ToolOutcome.success + ) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + toolCallId: 'tool-async-workflow', + phase: MothershipStreamV1ToolPhase.result, + status: MothershipStreamV1ToolOutcome.success, + success: true, + }), + }) + ) + }) + it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { waitForClientToolCompletion.mockResolvedValueOnce({ status: 'success', diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index a9f3b2f340b..9606e8e5261 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -771,8 +771,15 @@ async function dispatchToolExecution( if (completion) { span.setAttribute(TraceAttr.ToolOutcome, completion.status) } - handleClientCompletion(toolCall, toolCallId, completion) - await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) + const backgroundIsSuccess = toolName === 'run_workflow' && args?.async === true + handleClientCompletion(toolCall, toolCallId, completion, backgroundIsSuccess) + await emitSyntheticToolResult( + toolCallId, + toolCall.name, + completion, + options, + backgroundIsSuccess + ) return ( completion ?? { status: MothershipStreamV1ToolOutcome.error, diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index e35c1ba5efc..3e383e2c6fc 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -194,11 +194,14 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): { export function handleClientCompletion( toolCall: ToolCallState, toolCallId: string, - completion: AsyncTerminalCompletionSnapshot | null + completion: AsyncTerminalCompletionSnapshot | null, + backgroundIsSuccess = false ): void { if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { setTerminalToolCallState(toolCall, { - status: MothershipStreamV1ToolOutcome.skipped, + status: backgroundIsSuccess + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.skipped, ...(completion.data !== undefined ? { output: completion.data } : {}), }) markToolResultSeen(toolCallId) @@ -231,14 +234,17 @@ export async function emitSyntheticToolResult( toolCallId: string, toolName: string, completion: AsyncTerminalCompletionSnapshot | null, - options: OrchestratorOptions + options: OrchestratorOptions, + backgroundIsSuccess = false ): Promise { const isBackground = completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background const success = isBackground || completion?.status === MothershipStreamV1ToolOutcome.success const isCancelled = completion?.status === MothershipStreamV1ToolOutcome.cancelled const completionData = completion?.data const syntheticStatus = isBackground - ? MothershipStreamV1ToolOutcome.skipped + ? backgroundIsSuccess + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.skipped : completion?.status === MothershipStreamV1ToolOutcome.success || completion?.status === MothershipStreamV1ToolOutcome.error || completion?.status === MothershipStreamV1ToolOutcome.cancelled diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts index 465e44b1e70..8e0456dae08 100644 --- a/apps/sim/lib/copilot/request/tools/client.test.ts +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -265,6 +265,40 @@ describe('workflow client tool completion', () => { expect(JSON.stringify(completion)).not.toContain('untrusted') }) + it('preserves an allowlisted async deployment failure without an execution identity', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + code: 'ASYNC_WORKFLOW_DEPLOYMENT_STALE', + error: 'untrusted client detail', + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Async execution requires the current workflow to match its deployed version', + data: { + success: false, + workflowId: 'workflow-1', + code: 'ASYNC_WORKFLOW_DEPLOYMENT_STALE', + error: 'Async execution requires the current workflow to match its deployed version', + }, + }) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted client detail') + }) + it('uses the bound execution status when provenance is incomplete', async () => { const registry = createParentRegistry() waitForToolConfirmation.mockResolvedValue({ diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 4e40cdc4f83..b020c32119a 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -15,7 +15,9 @@ import { } from '@/lib/copilot/request/tools/client-completion-seal.server' import { inspectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { + type AsyncWorkflowDeploymentError, createStructuralWorkflowToolCompletionData, + getAsyncWorkflowDeploymentError, getWorkflowToolCompletionExecutionId, getWorkflowToolCompletionMessage, getWorkflowToolConfirmationStatus, @@ -204,12 +206,18 @@ interface WaitForWorkflowToolCompletionOptions { function structuralWorkflowCompletion( status: AsyncTerminalCompletionSnapshot['status'], workflowId?: string, - executionId?: string + executionId?: string, + deploymentError?: AsyncWorkflowDeploymentError ): AsyncTerminalCompletionSnapshot { return { status, - message: getWorkflowToolCompletionMessage(status), - data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + message: deploymentError?.message ?? getWorkflowToolCompletionMessage(status), + data: createStructuralWorkflowToolCompletionData( + status, + workflowId, + executionId, + deploymentError + ), } } @@ -237,6 +245,7 @@ export async function waitForWorkflowToolCompletion({ } const executionId = getWorkflowToolCompletionExecutionId(completion.data) + const deploymentError = getAsyncWorkflowDeploymentError(completion.data) if (completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { toolRegistry?.markIncomplete() return structuralWorkflowCompletion(completion.status, workflowId, executionId) @@ -247,7 +256,12 @@ export async function waitForWorkflowToolCompletion({ completion.status === MothershipStreamV1ToolOutcome.success ? MothershipStreamV1ToolOutcome.error : completion.status - return structuralWorkflowCompletion(structuralStatus, workflowId, executionId) + return structuralWorkflowCompletion( + structuralStatus, + workflowId, + executionId, + deploymentError + ) } try { diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index f3b48d6533f..d0299ac419c 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -109,6 +109,7 @@ import { bindRunToolToExecution, cancelRunToolExecution, executeRunToolOnClient, + isRunToolActiveForId, reportManualRunToolStop, } from './run-tool-execution' @@ -200,6 +201,128 @@ describe('run tool execution cancellation', () => { expect(fetch.mock.calls[0][1]?.body).not.toContain('raw-secret') }) + it('queues run_workflow asynchronously and reports the execution as background', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 202, + json: vi.fn().mockResolvedValue({ + success: true, + async: true, + executionId: 'exec-async', + }), + }) + .mockResolvedValueOnce({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + executeRunToolOnClient('tool-async', 'run_workflow', { + workflowId: 'wf-1', + workflow_input: { prompt: 'long-running task' }, + triggerBlockId: 'trigger-async', + async: true, + }) + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + + expect(executeWorkflowWithFullLogging).not.toHaveBeenCalled() + expect(setIsExecuting).not.toHaveBeenCalled() + expect(fetchMock.mock.calls[0][0]).toBe('/api/workflows/wf-1/execute') + expect(fetchMock.mock.calls[0][1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Execution-Mode': 'async', + }, + }) + ) + expect(JSON.parse(fetchMock.mock.calls[0][1]?.body as string)).toMatchObject({ + input: { prompt: 'long-running task' }, + triggerType: 'copilot', + triggerBlockId: 'trigger-async', + isClientSession: true, + copilotToolCallId: 'tool-async', + }) + expect(fetchMock.mock.calls[1][0]).toBe('/api/copilot/confirm') + expect(fetchMock.mock.calls[1][1]?.body).toContain('"status":"background"') + expect(fetchMock.mock.calls[1][1]?.body).toContain('"executionId":"exec-async"') + expect(saveExecutionPointer).toHaveBeenCalledWith({ + workflowId: 'wf-1', + executionId: 'exec-async', + lastEventId: 0, + }) + expect(clearExecutionPointer).toHaveBeenCalledWith('wf-1') + }) + + it('recovers a queued async launch by re-reporting it without enqueueing again', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 202, + json: vi.fn().mockResolvedValue({ executionId: 'exec-recover-async' }), + }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + executeRunToolOnClient('tool-recover-async', 'run_workflow', { + workflowId: 'wf-1', + async: true, + }) + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => expect(isRunToolActiveForId('tool-recover-async')).toBe(false)) + loadExecutionPointer.mockResolvedValueOnce({ + workflowId: 'wf-1', + executionId: 'exec-recover-async', + lastEventId: 0, + }) + + await expect(bindRunToolToExecution('tool-recover-async', 'wf-1')).resolves.toBe(true) + + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(fetchMock.mock.calls[3][0]).toBe('/api/copilot/confirm') + expect(fetchMock.mock.calls[3][1]?.body).toContain('"status":"background"') + expect(fetchMock.mock.calls[3][1]?.body).toContain('"executionId":"exec-recover-async"') + expect( + fetchMock.mock.calls.filter(([url]) => url === '/api/workflows/wf-1/execute') + ).toHaveLength(1) + expect(clearExecutionPointer).toHaveBeenCalledWith('wf-1') + }) + + it('reports a stale deployment as an async tool failure without queueing completion', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: vi.fn().mockResolvedValue({ + error: 'Async execution requires the current workflow to match its deployed version', + code: 'ASYNC_WORKFLOW_DEPLOYMENT_STALE', + }), + }) + .mockResolvedValueOnce({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + executeRunToolOnClient('tool-stale', 'run_workflow', { + workflowId: 'wf-1', + async: true, + }) + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + + expect(fetchMock.mock.calls[1][0]).toBe('/api/copilot/confirm') + expect(fetchMock.mock.calls[1][1]?.body).toContain('"status":"error"') + expect(fetchMock.mock.calls[1][1]?.body).toContain( + 'Async execution requires the current workflow to match its deployed version' + ) + expect(fetchMock.mock.calls[1][1]?.body).toContain('"code":"ASYNC_WORKFLOW_DEPLOYMENT_STALE"') + expect(fetchMock.mock.calls[1][1]?.body).not.toContain('"status":"background"') + }) + it('reports the workflow execution id with terminal error results', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 032eb5a2447..4e7e6e2340d 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -14,13 +14,18 @@ import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothershi import { RunBlock, RunFromBlock, + RunWorkflow, RunWorkflowUntilBlock, } from '@/lib/copilot/generated/tool-catalog-v1' import { CompletionReportError, reportClientToolCompletion as reportCompletion, } from '@/lib/copilot/tools/client/completion' -import { getWorkflowToolCompletionMessage } from '@/lib/copilot/tools/workflow-tools' +import { + type AsyncWorkflowDeploymentError, + getAsyncWorkflowDeploymentError, + getWorkflowToolCompletionMessage, +} from '@/lib/copilot/tools/workflow-tools' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { isExecutionStreamHttpError, @@ -45,6 +50,7 @@ const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:' interface PendingCompletionReport { status: AsyncConfirmationStatus executionId?: string + clearExecutionPointerAfterReport?: boolean } function resolveWorkflowInput(params: Record): unknown { @@ -63,6 +69,123 @@ function resolveTriggerBlockId(params: Record): string | undefi : undefined } +async function enqueueAsyncWorkflowRun( + toolCallId: string, + workflowId: string, + params: Record, + workflowInput: unknown, + triggerBlockId: string | undefined +): Promise { + const requestedExecutionId = generateId() + const inputFromExecutionId = + typeof params.inputFromExecutionId === 'string' && params.inputFromExecutionId.length > 0 + ? params.inputFromExecutionId + : undefined + + logger.info('[RunTool] Queueing asynchronous workflow execution', { + toolCallId, + workflowId, + executionId: requestedExecutionId, + hasInput: workflowInput !== undefined, + triggerBlockId, + }) + + let responseExecutionId = requestedExecutionId + let acceptanceIsAmbiguous = false + let deploymentError: AsyncWorkflowDeploymentError | undefined + try { + // boundary-raw-fetch: this execution endpoint switches to a JSON 202 response via X-Execution-Mode + const response = await fetch(`/api/workflows/${workflowId}/execute`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Execution-Mode': 'async', + }, + body: JSON.stringify({ + input: workflowInput, + executionId: requestedExecutionId, + triggerType: 'copilot', + isClientSession: true, + copilotToolCallId: toolCallId, + ...(triggerBlockId ? { triggerBlockId } : {}), + ...(workflowInput === undefined && inputFromExecutionId ? { inputFromExecutionId } : {}), + }), + }) + const responseBody: unknown = await response.json().catch(() => undefined) + deploymentError = getAsyncWorkflowDeploymentError(responseBody) + responseExecutionId = + isPlainRecord(responseBody) && typeof responseBody.executionId === 'string' + ? responseBody.executionId + : requestedExecutionId + acceptanceIsAmbiguous = + isPlainRecord(responseBody) && responseBody.code === 'ASYNC_ENQUEUE_AMBIGUOUS' + + if (!response.ok && !acceptanceIsAmbiguous) { + const responseError = + deploymentError?.message ?? + (isPlainRecord(responseBody) && typeof responseBody.error === 'string' + ? responseBody.error + : `Async workflow queue request failed with status ${response.status}`) + throw new Error(responseError) + } + } catch (error) { + const message = toError(error).message + logger.error('[RunTool] Failed to queue asynchronous workflow execution', { + toolCallId, + workflowId, + error: message, + }) + await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, message, { + success: false, + workflowId, + ...(deploymentError ? { code: deploymentError.code } : {}), + }) + return + } + + const pendingCompletion: PendingCompletionReport = { + status: ASYNC_TOOL_CONFIRMATION_STATUS.background, + executionId: responseExecutionId, + clearExecutionPointerAfterReport: true, + } + await saveExecutionPointer({ + workflowId, + executionId: responseExecutionId, + lastEventId: 0, + }) + savePendingCompletionReport(toolCallId, pendingCompletion) + + try { + await reportCompletion( + toolCallId, + pendingCompletion.status, + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId + ) + clearPendingCompletionReport(toolCallId) + await clearExecutionPointer(workflowId) + } catch (error) { + logger.error( + '[RunTool] Async workflow was queued but background status could not be reported', + { + toolCallId, + workflowId, + executionId: responseExecutionId, + error: toError(error).message, + } + ) + return + } + + logger.info('[RunTool] Asynchronous workflow execution queued', { + toolCallId, + workflowId, + executionId: responseExecutionId, + acceptanceIsAmbiguous, + }) +} + function pendingCompletionStorageKey(toolCallId: string): string { return `${PENDING_COMPLETION_STORAGE_PREFIX}${toolCallId}` } @@ -155,6 +278,9 @@ export async function bindRunToolToExecution( pendingCompletion.executionId ?? pointer.executionId ) clearPendingCompletionReport(toolCallId) + if (pendingCompletion.clearExecutionPointerAfterReport) { + await clearExecutionPointer(workflowId) + } } catch (error) { logger.warn('[RunTool] Failed to report recovered terminal completion', { workflowId, @@ -327,6 +453,23 @@ async function doExecuteRunTool( const triggerBlockId = resolveTriggerBlockId(params) const useDraftState = params.useDeployedState !== true + if (toolName === RunWorkflow.id && params.async === true) { + try { + await enqueueAsyncWorkflowRun( + toolCallId, + targetWorkflowId, + params, + workflowInput, + triggerBlockId + ) + } finally { + if (activeRunToolByWorkflowId.get(targetWorkflowId) === toolCallId) { + activeRunToolByWorkflowId.delete(targetWorkflowId) + } + } + return + } + const stopAfterBlockId = (() => { if (toolName === RunWorkflowUntilBlock.id) return params.stopAfterBlockId as string | undefined if (toolName === RunBlock.id) return params.blockId as string | undefined diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index a111a87f854..a7ece423150 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -48,6 +48,8 @@ export interface RunWorkflowParams { workflowId?: string workflow_input?: unknown input?: unknown + /** Queue the deployed workflow and return immediately instead of waiting for its output. */ + async?: boolean /** Optional trigger block ID when the workflow has multiple entrypoints and the caller wants a specific one. */ triggerBlockId?: string /** When true, run with the resolved trigger's generated mock payload instead of workflow_input. */ diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index c7c6c103d92..b493f55c193 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -13,6 +13,24 @@ const WORKFLOW_TOOL_NAMES = [ const WORKFLOW_TOOL_NAME_SET = new Set(WORKFLOW_TOOL_NAMES) +export const ASYNC_WORKFLOW_DEPLOYMENT_ERRORS = { + missing: { + code: 'ASYNC_WORKFLOW_DEPLOYMENT_MISSING', + message: 'Async execution requires the workflow to be deployed first', + }, + stale: { + code: 'ASYNC_WORKFLOW_DEPLOYMENT_STALE', + message: 'Async execution requires the current workflow to match its deployed version', + }, +} as const + +export type AsyncWorkflowDeploymentError = + (typeof ASYNC_WORKFLOW_DEPLOYMENT_ERRORS)[keyof typeof ASYNC_WORKFLOW_DEPLOYMENT_ERRORS] + +const ASYNC_WORKFLOW_DEPLOYMENT_ERROR_BY_CODE = new Map( + Object.values(ASYNC_WORKFLOW_DEPLOYMENT_ERRORS).map((error) => [error.code, error]) +) + export function isWorkflowToolName(name: string): boolean { return WORKFLOW_TOOL_NAME_SET.has(name) } @@ -35,6 +53,14 @@ export function getWorkflowToolCompletionExecutionId(data: unknown): string | un : undefined } +/** Restores only server-defined async deployment failures from client confirmation data. */ +export function getAsyncWorkflowDeploymentError( + data: unknown +): AsyncWorkflowDeploymentError | undefined { + if (!isPlainRecord(data) || typeof data.code !== 'string') return undefined + return ASYNC_WORKFLOW_DEPLOYMENT_ERROR_BY_CODE.get(data.code) +} + export function getWorkflowToolCompletionMessage(status: AsyncConfirmationStatus): string { if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) { return 'Workflow execution completed.' @@ -59,7 +85,8 @@ export function getWorkflowToolConfirmationStatus( export function createStructuralWorkflowToolCompletionData( status: AsyncConfirmationStatus, workflowId?: string, - executionId?: string + executionId?: string, + deploymentError?: AsyncWorkflowDeploymentError ): Record { const data: Record = {} if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) data.success = true @@ -71,6 +98,10 @@ export function createStructuralWorkflowToolCompletionData( } if (workflowId) data.workflowId = workflowId if (executionId) data.executionId = executionId + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.error && deploymentError) { + data.code = deploymentError.code + data.error = deploymentError.message + } if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { data.reason = 'user_cancelled' data.cancelledByUser = true diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts index 7968fe18c43..1fef3ecbe3a 100644 --- a/apps/sim/lib/execution/preprocessing.test.ts +++ b/apps/sim/lib/execution/preprocessing.test.ts @@ -61,7 +61,7 @@ vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { preprocessExecution } from './preprocessing' +import { preprocessExecution, WORKFLOW_NOT_DEPLOYED_CODE } from './preprocessing' const ORGANIZATION_ATTRIBUTION = { actorUserId: 'actor-1', @@ -115,6 +115,34 @@ beforeEach(() => { mockReserveExecutionSlot.mockResolvedValue({ reserved: true }) }) +describe('preprocessExecution deployment checks', () => { + it('returns a structured code when a required deployment is missing', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValueOnce({ + id: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + isDeployed: false, + }) + const result = await preprocessExecution({ + workflowId: 'workflow-1', + userId: 'user-1', + triggerType: 'copilot', + executionId: 'execution-1', + requestId: 'request-1', + checkDeployment: true, + }) + + expect(result).toEqual({ + success: false, + error: { + message: 'Workflow is not deployed', + statusCode: 403, + code: WORKFLOW_NOT_DEPLOYED_CODE, + }, + }) + }) +}) + describe('preprocessExecution correlation logging', () => { it('preserves trigger correlation when logging preprocessing failures', async () => { mockResolveSystemBillingAttribution.mockRejectedValueOnce( diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index e6d4fdf2149..710ba346d90 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -108,6 +108,8 @@ export interface PreprocessExecutionError { cause?: Record } +export const WORKFLOW_NOT_DEPLOYED_CODE = 'WORKFLOW_NOT_DEPLOYED' + export interface PreprocessExecutionSuccess { success: true actorUserId: string @@ -277,6 +279,7 @@ export async function preprocessExecution( error: { message: 'Workflow is not deployed', statusCode: 403, + code: WORKFLOW_NOT_DEPLOYED_CODE, }, } } diff --git a/apps/sim/lib/webhooks/providers/zoho-desk.test.ts b/apps/sim/lib/webhooks/providers/zoho-desk.test.ts index 969c2672134..3c516cfdbab 100644 --- a/apps/sim/lib/webhooks/providers/zoho-desk.test.ts +++ b/apps/sim/lib/webhooks/providers/zoho-desk.test.ts @@ -47,7 +47,6 @@ describe('zohoDeskHandler', () => { it('rejects requests without the X-ZDesk-JWT header', async () => { const result = await zohoDeskHandler.verifyAuth?.( - // biome-ignore lint/suspicious/noExplicitAny: minimal context for the header-only path makeAuthContext({}, { orgId: '1', webhookId: '2' }) as any ) expect(result).not.toBeNull() @@ -58,13 +57,11 @@ describe('zohoDeskHandler', () => { vi.mocked(getCredentialOwner).mockResolvedValue({ accountId: 'acct-1', userId: 'u1', - // biome-ignore lint/suspicious/noExplicitAny: partial owner shape is enough for this path } as any) await zohoDeskHandler.verifyAuth?.( makeAuthContext( { 'x-zdesk-jwt': 'not-a-real-jwt' }, { orgId: '1', externalId: '2', credentialId: 'cred-1' } - // biome-ignore lint/suspicious/noExplicitAny: minimal context for the fallback path ) as any ) expect(getCredentialOwner).toHaveBeenCalledWith('cred-1', 'test') @@ -75,7 +72,6 @@ describe('zohoDeskHandler', () => { makeAuthContext( { 'x-zdesk-jwt': 'not-a-real-jwt' }, { orgId: '1', externalId: '2', credentialId: 'cred-1', apiDomain: 'https://desk.zoho.eu' } - // biome-ignore lint/suspicious/noExplicitAny: minimal context for the fast path ) as any ) expect(getCredentialOwner).not.toHaveBeenCalled() @@ -90,7 +86,6 @@ describe('zohoDeskHandler', () => { workflow: {}, userId: 'user-1', requestId: 'test', - // biome-ignore lint/suspicious/noExplicitAny: request is unused on these guard paths request: {} as any, }) } catch (error) { @@ -247,7 +242,6 @@ describe('zohoDeskHandler', () => { vi.mocked(getCredentialOwner).mockResolvedValue({ accountId: 'acc-1', userId: 'user-1', - // biome-ignore lint/suspicious/noExplicitAny: partial owner shape for the test } as any) vi.mocked(refreshAccessTokenIfNeeded).mockResolvedValue('zoho-token') @@ -270,7 +264,6 @@ describe('zohoDeskHandler', () => { workflow: {}, userId: 'user-1', requestId: 'test', - // biome-ignore lint/suspicious/noExplicitAny: request is unused on this path request: {} as any, }) @@ -288,7 +281,6 @@ describe('zohoDeskHandler', () => { vi.mocked(getCredentialOwner).mockResolvedValue({ accountId: 'acc-1', userId: 'user-1', - // biome-ignore lint/suspicious/noExplicitAny: partial owner shape for the test } as any) vi.mocked(refreshAccessTokenIfNeeded).mockResolvedValue('zoho-token') @@ -311,7 +303,6 @@ describe('zohoDeskHandler', () => { workflow: {}, userId: 'user-1', requestId: 'test', - // biome-ignore lint/suspicious/noExplicitAny: request is unused on this path request: {} as any, }) return sentBody diff --git a/packages/testing/src/mocks/execution-preprocessing.mock.ts b/packages/testing/src/mocks/execution-preprocessing.mock.ts index dab2f8d4154..399121c836d 100644 --- a/packages/testing/src/mocks/execution-preprocessing.mock.ts +++ b/packages/testing/src/mocks/execution-preprocessing.mock.ts @@ -28,4 +28,5 @@ export const executionPreprocessingMockFns = { */ export const executionPreprocessingMock = { preprocessExecution: executionPreprocessingMockFns.mockPreprocessExecution, + WORKFLOW_NOT_DEPLOYED_CODE: 'WORKFLOW_NOT_DEPLOYED', }