From 6382eb54bc109340208ea574203c35a553842e1c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 14 Jul 2026 13:15:53 -0700 Subject: [PATCH 1/6] improvement(webhooks): external subscription management --- .../content/docs/en/integrations/airtable.mdx | 3 + apps/sim/app/api/chat/manage/[id]/route.ts | 1 - .../app/api/cron/renew-subscriptions/route.ts | 3 +- .../api/mcp/serve/[serverId]/route.test.ts | 11 +- .../sim/app/api/mcp/serve/[serverId]/route.ts | 22 +- apps/sim/app/api/schedules/execute/route.ts | 2 + .../app/api/tools/deployments/deploy/route.ts | 6 +- .../api/tools/deployments/promote/route.ts | 6 +- apps/sim/app/api/v1/admin/types.ts | 12 - .../v1/admin/workflows/[id]/deploy/route.ts | 23 +- .../versions/[versionId]/activate/route.ts | 8 +- .../v1/workflows/[id]/deploy/route.test.ts | 29 +- .../app/api/v1/workflows/[id]/deploy/route.ts | 16 +- .../v1/workflows/[id]/rollback/route.test.ts | 18 + .../api/v1/workflows/[id]/rollback/route.ts | 14 +- .../app/api/workflows/[id]/deploy/route.ts | 47 +- .../[id]/deployments/[version]/route.ts | 25 +- .../app/api/workflows/[id]/execute/route.ts | 17 +- .../general/components/versions.tsx | 36 +- .../components/deploy-modal/deploy-modal.tsx | 140 +- .../condition-input/condition-input.tsx | 24 +- apps/sim/background/schedule-execution.ts | 23 +- apps/sim/background/webhook-execution.test.ts | 68 +- apps/sim/background/webhook-execution.ts | 19 +- apps/sim/blocks/blocks/airtable.test.ts | 109 + apps/sim/blocks/blocks/airtable.ts | 12 +- .../lib/promote/reactivate-in-tx.ts | 75 +- .../workspace-forking/lib/promote/rollback.ts | 2 +- apps/sim/executor/constants.ts | 5 - .../condition/condition-handler.test.ts | 35 + .../handlers/condition/condition-handler.ts | 19 +- apps/sim/hooks/queries/deployments.ts | 63 +- apps/sim/lib/api/contracts/deployments.ts | 52 +- .../lib/api/contracts/v1/admin/workflows.ts | 16 +- apps/sim/lib/api/contracts/v1/workflows.ts | 22 +- apps/sim/lib/api/contracts/workflows.ts | 2 + .../tools/handlers/deployment/deploy.ts | 17 +- .../tools/handlers/deployment/manage.ts | 20 +- .../workflow/edit-workflow/builders.test.ts | 13 +- .../server/workflow/edit-workflow/builders.ts | 4 +- apps/sim/lib/core/outbox/service.test.ts | 24 + apps/sim/lib/core/outbox/service.ts | 13 + .../lib/webhooks/delivery-predicate.test.ts | 55 + apps/sim/lib/webhooks/delivery-predicate.ts | 21 + apps/sim/lib/webhooks/deploy.ts | 509 +- apps/sim/lib/webhooks/path-claims.test.ts | 109 + apps/sim/lib/webhooks/path-claims.ts | 117 + apps/sim/lib/webhooks/polling/utils.ts | 4 +- apps/sim/lib/webhooks/processor.test.ts | 38 +- apps/sim/lib/webhooks/processor.ts | 65 +- .../webhooks/provider-subscriptions.test.ts | 46 +- .../lib/webhooks/provider-subscriptions.ts | 50 +- apps/sim/lib/webhooks/providers/gmail.ts | 47 +- apps/sim/lib/webhooks/providers/imap.ts | 42 +- apps/sim/lib/webhooks/providers/outlook.ts | 59 +- apps/sim/lib/webhooks/providers/rss.ts | 32 +- .../lib/webhooks/providers/tiktok-targets.ts | 4 +- apps/sim/lib/webhooks/providers/types.ts | 6 +- .../webhooks/registration-identity.test.ts | 132 + .../sim/lib/webhooks/registration-identity.ts | 99 + .../registration-reconciliation.test.ts | 158 + .../webhooks/registration-reconciliation.ts | 110 + .../lib/webhooks/registration-service.test.ts | 277 + apps/sim/lib/webhooks/registration-service.ts | 366 + .../lib/webhooks/registration-store.test.ts | 290 + apps/sim/lib/webhooks/registration-store.ts | 552 + apps/sim/lib/webhooks/utils.server.test.ts | 109 + apps/sim/lib/webhooks/utils.server.ts | 36 +- apps/sim/lib/workflows/conditions.test.ts | 23 + apps/sim/lib/workflows/conditions.ts | 9 + .../workflows/deployment-lifecycle.test.ts | 52 + .../sim/lib/workflows/deployment-lifecycle.ts | 189 + .../lib/workflows/deployment-outbox.test.ts | 505 + apps/sim/lib/workflows/deployment-outbox.ts | 761 +- .../executor/execution-state.test.ts | 173 + .../lib/workflows/executor/execution-state.ts | 92 +- apps/sim/lib/workflows/lifecycle.test.ts | 11 +- apps/sim/lib/workflows/lifecycle.ts | 5 + .../workflows/orchestration/chat-deploy.ts | 8 + .../workflows/orchestration/deploy.test.ts | 442 +- .../sim/lib/workflows/orchestration/deploy.ts | 562 +- apps/sim/lib/workflows/orchestration/index.ts | 1 + .../lib/workflows/orchestration/types.test.ts | 17 + apps/sim/lib/workflows/orchestration/types.ts | 1 + .../persistence/deployment-operations.test.ts | 436 + .../persistence/deployment-operations.ts | 865 + .../lib/workflows/persistence/utils.test.ts | 85 +- apps/sim/lib/workflows/persistence/utils.ts | 580 +- .../lib/workflows/schedules/deploy.test.ts | 4 +- apps/sim/lib/workflows/schedules/deploy.ts | 37 +- apps/sim/lib/workspace-events/no-activity.ts | 4 +- .../sim/lib/workspace-events/subscriptions.ts | 4 +- .../stores/workflows/registry/store.test.ts | 37 + apps/sim/stores/workflows/registry/store.ts | 7 + apps/sim/tools/airtable/airtable.test.ts | 92 + apps/sim/tools/airtable/create_records.ts | 11 +- apps/sim/tools/airtable/types.ts | 34 +- .../tools/airtable/update_multiple_records.ts | 11 +- apps/sim/tools/airtable/update_record.ts | 11 +- .../migrations/0261_tranquil_donald_blake.sql | 87 + .../db/migrations/meta/0261_snapshot.json | 17352 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 125 + packages/testing/src/mocks/schema.mock.ts | 32 + 104 files changed, 25682 insertions(+), 1399 deletions(-) create mode 100644 apps/sim/blocks/blocks/airtable.test.ts create mode 100644 apps/sim/lib/webhooks/delivery-predicate.test.ts create mode 100644 apps/sim/lib/webhooks/delivery-predicate.ts create mode 100644 apps/sim/lib/webhooks/path-claims.test.ts create mode 100644 apps/sim/lib/webhooks/path-claims.ts create mode 100644 apps/sim/lib/webhooks/registration-identity.test.ts create mode 100644 apps/sim/lib/webhooks/registration-identity.ts create mode 100644 apps/sim/lib/webhooks/registration-reconciliation.test.ts create mode 100644 apps/sim/lib/webhooks/registration-reconciliation.ts create mode 100644 apps/sim/lib/webhooks/registration-service.test.ts create mode 100644 apps/sim/lib/webhooks/registration-service.ts create mode 100644 apps/sim/lib/webhooks/registration-store.test.ts create mode 100644 apps/sim/lib/webhooks/registration-store.ts create mode 100644 apps/sim/lib/webhooks/utils.server.test.ts create mode 100644 apps/sim/lib/workflows/conditions.test.ts create mode 100644 apps/sim/lib/workflows/conditions.ts create mode 100644 apps/sim/lib/workflows/deployment-lifecycle.test.ts create mode 100644 apps/sim/lib/workflows/deployment-lifecycle.ts create mode 100644 apps/sim/lib/workflows/deployment-outbox.test.ts create mode 100644 apps/sim/lib/workflows/executor/execution-state.test.ts create mode 100644 apps/sim/lib/workflows/orchestration/types.test.ts create mode 100644 apps/sim/lib/workflows/persistence/deployment-operations.test.ts create mode 100644 apps/sim/lib/workflows/persistence/deployment-operations.ts create mode 100644 apps/sim/tools/airtable/airtable.test.ts create mode 100644 packages/db/migrations/0261_tranquil_donald_blake.sql create mode 100644 packages/db/migrations/meta/0261_snapshot.json diff --git a/apps/docs/content/docs/en/integrations/airtable.mdx b/apps/docs/content/docs/en/integrations/airtable.mdx index 63314699c05..ee76053d391 100644 --- a/apps/docs/content/docs/en/integrations/airtable.mdx +++ b/apps/docs/content/docs/en/integrations/airtable.mdx @@ -142,6 +142,7 @@ Write new records to an Airtable table | `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) | | `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name | | `records` | json | Yes | Array of records to create, each with a `fields` object | +| `typecast` | boolean | No | When true, Airtable automatically converts string values to the field type | #### Output @@ -166,6 +167,7 @@ Update an existing record in an Airtable table by ID | `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name | | `recordId` | string | Yes | Record ID to update \(starts with "rec", e.g., "recXXXXXXXXXXXXXX"\) | | `fields` | json | Yes | An object containing the field names and their new values | +| `typecast` | boolean | No | When true, Airtable automatically converts string values to the field type | #### Output @@ -190,6 +192,7 @@ Update multiple existing records in an Airtable table | `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) | | `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name | | `records` | json | Yes | Array of records to update, each with an `id` and a `fields` object | +| `typecast` | boolean | No | When true, Airtable automatically converts string values to the field type | #### Output diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 707051d7e9f..3dffe9ccb37 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -140,7 +140,6 @@ export const PATCH = withRouteHandler( const deployResult = await performFullDeploy({ workflowId: existingChat[0].workflowId, userId: session.user.id, - request, }) if (!deployResult.success) { diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index 4728f0fd666..97f00a26ff2 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import { getCredentialOwner, getNotificationUrl } from '@/lib/webhooks/provider-subscription-utils' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -96,7 +97,7 @@ async function renewExpiringSubscriptions(): Promise<{ .from(webhookTable) .where( and( - eq(webhookTable.isActive, true), + deliverableWebhookPredicate(webhookTable, 'active_only'), or( eq(webhookTable.provider, 'microsoft-teams'), eq(webhookTable.provider, 'microsoftteams') diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index f0af2021175..9ea057e4e6c 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -278,7 +278,9 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([ + { isDeployed: true, workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }, + ]) hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, @@ -683,7 +685,9 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([ + { isDeployed: true, workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }, + ]) fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -716,6 +720,9 @@ describe('MCP Serve Route', () => { const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit const headers = fetchOptions.headers as Record expect(headers['X-Sim-MCP-Tool-Call']).toBe('true') + expect(JSON.parse(fetchOptions.body as string)).toMatchObject({ + deploymentVersionId: 'deployment-1', + }) }) it('preserves downstream attributed usage admission rejections', async () => { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 5186d6c3410..f593e44db68 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -18,7 +18,13 @@ import { type Tool, } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' -import { workflow, workflowMcpServer, workflowMcpTool, workspace } from '@sim/db/schema' +import { + workflow, + workflowDeploymentVersion, + workflowMcpServer, + workflowMcpTool, + workspace, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -764,8 +770,19 @@ async function handleToolsCall( } const [wf] = await db - .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId }) + .select({ + isDeployed: workflow.isDeployed, + workspaceId: workflow.workspaceId, + deploymentVersionId: workflowDeploymentVersion.id, + }) .from(workflow) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) .where(and(eq(workflow.id, tool.workflowId), isNull(workflow.archivedAt))) .limit(1) const abortedAfterWorkflowLookup = callerAbortedJsonRpcResponse(id, abortSignal) @@ -826,6 +843,7 @@ async function handleToolsCall( input: params.arguments || {}, triggerType: 'mcp', includeFileBase64: false, + ...(wf.deploymentVersionId ? { deploymentVersionId: wf.deploymentVersionId } : {}), }) assertKnownSizeWithinLimit( Buffer.byteLength(workflowRequestBody, 'utf-8'), diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 63a9258211d..13f87274810 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -165,6 +165,7 @@ async function claimWorkflowSchedules(queuedAt: Date, limit: number) { lastQueuedAt: workflowSchedule.lastQueuedAt, timezone: workflowSchedule.timezone, deploymentVersionId: workflowSchedule.deploymentVersionId, + deploymentOperationId: workflowSchedule.deploymentOperationId, sourceType: workflowSchedule.sourceType, }) @@ -864,6 +865,7 @@ async function processScheduleItem( workspaceId, billingAttribution, deploymentVersionId: schedule.deploymentVersionId || undefined, + deploymentOperationId: schedule.deploymentOperationId || undefined, cronExpression: schedule.cronExpression || undefined, timezone: schedule.timezone || undefined, lastRanAt: schedule.lastRanAt?.toISOString(), diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts index bf0ea376a84..5f35c91da86 100644 --- a/apps/sim/app/api/tools/deployments/deploy/route.ts +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -51,11 +51,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const result = await performFullDeploy({ workflowId, userId: auth.userId, - workflowName: access.workflow.name || undefined, versionName: name, versionDescription: description ?? undefined, requestId, - request, }) if (!result.success) { @@ -69,9 +67,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: true, output: { workflowId, - isDeployed: true, + isDeployed: Boolean(result.activeDeployment), deployedAt: result.deployedAt?.toISOString() ?? null, version: result.version, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings ?? [], }, }) diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts index 406b08b0b9d..a126c3dbd57 100644 --- a/apps/sim/app/api/tools/deployments/promote/route.ts +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -53,9 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workflowId, version, userId: auth.userId, - workflow: access.workflow as Record, requestId, - request, }) if (!result.success) { @@ -69,9 +67,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: true, output: { workflowId, - isDeployed: true, + isDeployed: Boolean(result.activeDeployment), deployedAt: result.deployedAt?.toISOString() ?? null, version, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings ?? [], }, }) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 04f61a8a964..4754de31054 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -629,18 +629,6 @@ export interface AdminDeploymentVersion { deployedByName: string | null } -export interface AdminDeployResult { - isDeployed: boolean - version: number - deployedAt: string - warnings?: string[] -} - -export interface AdminUndeployResult { - isDeployed: boolean - warnings?: string[] -} - // ============================================================================= // Audit Log Types // ============================================================================= 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 62fe6405bfd..5e844a00188 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,6 +1,8 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { + type AdminV1DeployResult, + type AdminV1UndeployResult, adminV1DeployWorkflowContract, adminV1UndeployWorkflowContract, } from '@/lib/api/contracts/v1/admin' @@ -15,7 +17,6 @@ import { notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowDeployAPI') export const maxDuration = 120 @@ -51,9 +52,7 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId, userId: workflowRecord.userId, - workflowName: workflowRecord.name, requestId, - request, actorId: 'admin-api', }) @@ -63,13 +62,19 @@ export const POST = withRouteHandler( return internalErrorResponse(result.error || 'Failed to deploy workflow') } - logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`) + const isDeployed = Boolean(result.activeDeployment) + const attemptActivated = result.latestDeploymentAttempt?.status === 'active' + logger.info( + `[${requestId}] Admin API: Deployment ${attemptActivated ? 'activated' : 'accepted'} for workflow ${workflowId}` + ) - const response: AdminDeployResult = { - isDeployed: true, - version: result.version!, - deployedAt: result.deployedAt!.toISOString(), + const response: AdminV1DeployResult = { + isDeployed, + version: result.version ?? null, + deployedAt: result.deployedAt?.toISOString() ?? null, warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, } return singleResponse(response) @@ -108,7 +113,7 @@ export const DELETE = withRouteHandler( logger.info(`Admin API: Undeployed workflow ${workflowId}`) - const response: AdminUndeployResult = { + const response: AdminV1UndeployResult = { isDeployed: false, warnings: result.warnings, } 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 360a817c5b6..4f5922c3745 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 @@ -40,9 +40,7 @@ export const POST = withRouteHandler( workflowId, version: versionNum, userId: workflowRecord.userId, - workflow: workflowRecord as Record, requestId, - request, actorId: 'admin-api', }) @@ -53,14 +51,16 @@ export const POST = withRouteHandler( } logger.info( - `[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}` + `[${requestId}] Admin API: ${result.latestDeploymentAttempt?.status === 'active' ? 'Activated' : 'Accepted activation for'} version ${versionNum} on workflow ${workflowId}` ) return singleResponse({ success: true, version: versionNum, - deployedAt: result.deployedAt!.toISOString(), + deployedAt: result.deployedAt?.toISOString() ?? null, warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, }) } catch (error) { logger.error( diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index dd78899e2ac..42e977bdfe2 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -82,6 +82,22 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { deployedAt: new Date('2026-06-12T00:00:00Z'), version: 4, warnings: undefined, + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, + latestDeploymentAttempt: { + id: 'op-1', + deploymentVersionId: 'dv-4', + version: 4, + action: 'deploy', + status: 'active', + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-06-12T00:00:00.000Z', + activatedAt: '2026-06-12T00:00:00.000Z', + error: null, + }, }) }) @@ -163,6 +179,8 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { deployedAt: '2026-06-12T00:00:00.000Z', version: 4, warnings: [], + activeDeployment: expect.objectContaining({ deploymentVersionId: 'dv-4', version: 4 }), + latestDeploymentAttempt: expect.objectContaining({ id: 'op-1', status: 'active' }), }) }) @@ -179,12 +197,11 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { versionDescription: 'Fixes the agent prompt', }) ) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'workflow_deployed', - expect.objectContaining({ workflow_id: WORKFLOW_ID }), - expect.anything() - ) + /** + * The workflow_deployed analytics event is emitted by the activation + * side effects in the deployment outbox, not by this route. + */ + expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) it('maps validation failures from the orchestration to 400', async () => { diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 008a45a9820..822e00ab2ca 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -60,11 +60,9 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId, - workflowName: workflow.name || undefined, versionName: body.data.name, versionDescription: body.data.description ?? undefined, requestId, - request, }) if (!result.success) { @@ -74,25 +72,19 @@ export const POST = withRouteHandler( ) } - captureServerEvent( - userId, - 'workflow_deployed', - { workflow_id: id, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) + const isDeployed = Boolean(result.activeDeployment) const limits = await getUserLimits(userId) const apiResponse = createApiResponse( { data: { id, - isDeployed: true, + isDeployed, deployedAt: result.deployedAt?.toISOString() ?? null, version: result.version, warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }, limits, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index cdebe9e35af..8ee6ec2940b 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -81,6 +81,22 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { mockPerformActivateVersion.mockResolvedValue({ success: true, deployedAt: new Date('2026-06-12T00:00:00Z'), + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, + latestDeploymentAttempt: { + id: 'op-1', + deploymentVersionId: 'dv-4', + version: 4, + action: 'activate', + status: 'active', + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-06-12T00:00:00.000Z', + activatedAt: '2026-06-12T00:00:00.000Z', + error: null, + }, }) }) @@ -128,6 +144,8 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { deployedAt: '2026-06-12T00:00:00.000Z', version: 4, warnings: [], + activeDeployment: expect.objectContaining({ deploymentVersionId: 'dv-4', version: 4 }), + latestDeploymentAttempt: expect.objectContaining({ id: 'op-1', status: 'active' }), }) }) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index 534e7fd89de..63ca166cdf1 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -9,7 +9,6 @@ import { import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' @@ -81,9 +80,7 @@ export const POST = withRouteHandler( workflowId: id, version: targetVersion, userId, - workflow: workflow as Record, requestId, - request, }) if (!result.success) { @@ -93,22 +90,17 @@ export const POST = withRouteHandler( ) } - captureServerEvent( - userId, - 'deployment_version_activated', - { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, - { groups: { workspace: workspaceId } } - ) - const limits = await getUserLimits(userId) const apiResponse = createApiResponse( { data: { id, - isDeployed: true, + isDeployed: Boolean(result.activeDeployment), deployedAt: result.deployedAt?.toISOString() ?? null, version: targetVersion, warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }, limits, diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 440636186e1..517fe693cfe 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -10,7 +10,11 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { + getWorkflowDeploymentSummary, + performFullDeploy, + performFullUndeploy, +} from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { @@ -44,7 +48,10 @@ export const GET = withRouteHandler( return createErrorResponse(error.message, error.status) } - if (!workflowData.isDeployed) { + const deploymentSummary = await getWorkflowDeploymentSummary(id) + const isDeployed = deploymentSummary.activeDeployment !== null || workflowData.isDeployed + + if (!isDeployed) { logger.info(`[${requestId}] Workflow is not deployed: ${id}`) return createSuccessResponse({ isDeployed: false, @@ -52,10 +59,17 @@ export const GET = withRouteHandler( apiKey: null, needsRedeployment: false, isPublicApi: workflowData.isPublicApi ?? false, + activeDeployment: deploymentSummary.activeDeployment, + latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + warnings: deploymentSummary.warnings, }) } - const needsRedeployment = await checkNeedsRedeployment(id) + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + const needsRedeployment = + attemptStatus === 'preparing' || attemptStatus === 'activating' + ? false + : await checkNeedsRedeployment(id) logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) @@ -65,10 +79,13 @@ export const GET = withRouteHandler( return createSuccessResponse({ apiKey: responseApiKeyInfo, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt, + isDeployed, + deployedAt: deploymentSummary.activeDeployment?.deployedAt ?? workflowData.deployedAt, needsRedeployment, isPublicApi: workflowData.isPublicApi ?? false, + activeDeployment: deploymentSummary.activeDeployment, + latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + warnings: deploymentSummary.warnings, }) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) @@ -102,9 +119,7 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId: actorUserId, - workflowName: workflowData!.name || undefined, requestId, - request, }) if (!result.success) { @@ -114,16 +129,10 @@ export const POST = withRouteHandler( ) } - logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - - captureServerEvent( - actorUserId, - 'workflow_deployed', - { workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' }, - { - groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } + const isDeployed = Boolean(result.activeDeployment) + const attemptActivated = result.latestDeploymentAttempt?.status === 'active' + logger.info( + `[${requestId}] Workflow deployment ${attemptActivated ? 'activated' : 'accepted for preparation'}: ${id}` ) const responseApiKeyInfo = workflowData!.workspaceId @@ -132,9 +141,11 @@ export const POST = withRouteHandler( return createSuccessResponse({ apiKey: responseApiKeyInfo, - isDeployed: true, + isDeployed, deployedAt: result.deployedAt, warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, }) } catch (error: unknown) { if (error instanceof WorkflowLockedError) { 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 751171b0b3b..d3c3337e62f 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -6,7 +6,6 @@ import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/dep import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { @@ -73,11 +72,11 @@ export const PATCH = withRouteHandler( // Activation requires admin permission, other updates require write const requiredPermission = isActive ? 'admin' : 'write' - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, requiredPermission) + const { error, session } = await validateWorkflowPermissions( + id, + requestId, + requiredPermission + ) if (error) { return createErrorResponse(error.message, error.status) } @@ -98,9 +97,7 @@ export const PATCH = withRouteHandler( workflowId: id, version: versionNum, userId: actorUserId, - workflow: workflowData as Record, requestId, - request, }) if (!activateResult.success) { @@ -145,18 +142,12 @@ export const PATCH = withRouteHandler( } } - const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId - captureServerEvent( - actorUserId, - 'deployment_version_activated', - { workflow_id: id, workspace_id: wsId ?? '', version: versionNum }, - wsId ? { groups: { workspace: wsId } } : undefined - ) - return createSuccessResponse({ success: true, - deployedAt: activateResult.deployedAt, + deployedAt: activateResult.deployedAt ?? null, warnings: activateResult.warnings, + activeDeployment: activateResult.activeDeployment ?? null, + latestDeploymentAttempt: activateResult.latestDeploymentAttempt ?? null, ...(updatedName !== undefined && { name: updatedName }), ...(updatedDescription !== undefined && { description: updatedDescription }), }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index a0ca81c0464..ceda6082aa1 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -85,6 +85,7 @@ import { import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { loadDeployedWorkflowState, + loadWorkflowDeploymentVersionState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' @@ -691,6 +692,7 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, + deploymentVersionId: admittedDeploymentVersionId, executionId: rawBodyExecutionId, triggerBlockId: parsedTriggerBlockId, startBlockId, @@ -699,6 +701,12 @@ async function handleExecutePost( parentWorkspaceId, } = validation.data const triggerBlockId = parsedTriggerBlockId ?? startBlockId + if (admittedDeploymentVersionId && !isMcpBridgeRequest) { + return NextResponse.json( + { error: 'deploymentVersionId is reserved for internal MCP execution' }, + { status: 400 } + ) + } const headerExecutionId = headerValidation.data[WORKFLOW_EXECUTION_ID_HEADER] let legacyBodyExecutionId: string | undefined if (!headerExecutionId && rawBodyExecutionId !== undefined) { @@ -829,6 +837,7 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, + deploymentVersionId: _deploymentVersionId, triggerBlockId: _triggerBlockId, stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, @@ -1100,7 +1109,13 @@ async function handleExecutePost( } const workflowData = shouldUseDraftState ? await loadWorkflowFromNormalizedTables(workflowId) - : await loadDeployedWorkflowState(workflowId, workspaceId) + : admittedDeploymentVersionId + ? await loadWorkflowDeploymentVersionState( + workflowId, + admittedDeploymentVersionId, + workspaceId + ) + : await loadDeployedWorkflowState(workflowId, workspaceId) if (req.signal.aborted) { await releaseExecutionSlot(executionId) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx index 14781118029..8d6c7306ab8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx @@ -207,6 +207,10 @@ export function Versions({
{versions.map((v) => { const isSelected = selectedVersion === v.version + const operationStatus = + !v.isActive && v.latestOperationStatus !== 'active' ? v.latestOperationStatus : null + const isOperationPending = + operationStatus === 'preparing' || operationStatus === 'activating' return (
{editingVersion === v.version ? ( (live) )} + {operationStatus && ( + + ({operationStatus}) + + )} {isSelected && ( (selected) )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 8d6d4f46ea2..06aa02bd7ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -22,6 +22,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' +import type { DeploymentOperationSummary } from '@/lib/api/contracts/deployments' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' @@ -180,7 +181,7 @@ export function DeployModal({ data: deploymentInfoData, isLoading: isLoadingDeploymentInfo, refetch: refetchDeploymentInfo, - } = useDeploymentInfo(workflowId, { enabled: open && isDeployed }) + } = useDeploymentInfo(workflowId, { enabled: open }) const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, { enabled: open, @@ -201,6 +202,21 @@ export function DeployModal({ const activateVersionMutation = useActivateDeploymentVersion() const versions = versionsData?.versions ?? [] + const deploymentAttemptStatus = deploymentInfoData?.latestDeploymentAttempt?.status + const hasTerminalDeploymentAttempt = + deploymentAttemptStatus === 'active' || + deploymentAttemptStatus === 'failed' || + deploymentAttemptStatus === 'superseded' + const isAttemptFailed = deploymentAttemptStatus === 'failed' + const attemptErrorMessage = isAttemptFailed + ? (deploymentInfoData?.latestDeploymentAttempt?.error?.message ?? + 'Deployment preparation failed') + : null + const displayedDeployWarnings = isAttemptFailed + ? [] + : hasTerminalDeploymentAttempt || deployWarnings.length === 0 + ? (deploymentInfoData?.warnings ?? []) + : deployWarnings const isWorkflowStillActive = (targetWorkflowId: string) => { return useWorkflowRegistry.getState().activeWorkflowId === targetWorkflowId @@ -340,7 +356,8 @@ export function DeployModal({ try { const result = await deployMutation.mutateAsync({ workflowId }) - const syncWarning = await syncDraftAfterDeploy() + const syncWarning = + result.latestDeploymentAttempt?.status === 'active' ? await syncDraftAfterDeploy() : null if (!isWorkflowStillActive(workflowId) || deployActionIdRef.current !== actionId) return setDeployWarnings([...(result.warnings || []), ...(syncWarning ? [syncWarning] : [])]) } finally { @@ -545,14 +562,37 @@ export function DeployModal({ Configure and manage workflow deployment settings including API, MCP, and chat options. - {(deployError || deployWarnings.length > 0) && ( -
+ {(deployError || attemptErrorMessage || displayedDeployWarnings.length > 0) && ( +
{deployError && ( - + {deployError} )} - {deployWarnings.map((warning) => ( + {!deployError && attemptErrorMessage && ( + + + + {attemptErrorMessage} + + + +

{attemptErrorMessage}

+

+ {isDeployed + ? 'The previously deployed version is still live.' + : 'The workflow remains undeployed.'} +

+
+
+ )} + {displayedDeployWarnings.map((warning) => ( { @@ -781,15 +823,68 @@ export function DeployModal({ ) } +type DeploymentAttemptStatus = DeploymentOperationSummary['status'] + interface StatusBadgeProps { - isWarning: boolean + isDeployed: boolean + needsRedeployment: boolean + attemptStatus?: DeploymentAttemptStatus + attemptErrorMessage?: string | null } -function StatusBadge({ isWarning }: StatusBadgeProps) { - const label = isWarning ? 'Update deployment' : 'Live' +/** + * Lifecycle-aware deployment status badge. Pending attempts render amber, + * failed attempts render red with the failure reason in a tooltip, and a + * settled live deployment falls back to the Live/Update states. + */ +function StatusBadge({ + isDeployed, + needsRedeployment, + attemptStatus, + attemptErrorMessage, +}: StatusBadgeProps) { + if (attemptStatus === 'preparing' || attemptStatus === 'activating') { + return ( + + + + Pending + + + + {isDeployed + ? 'A new version is being prepared. The current version stays live until cutover completes.' + : 'Triggers and schedules are being registered. The workflow goes live once activation completes.'} + + + ) + } + + if (attemptStatus === 'failed') { + return ( + + + + Failed + + + +

{attemptErrorMessage || 'Deployment preparation failed.'}

+

+ {isDeployed + ? 'The previously deployed version is still live.' + : 'The workflow remains undeployed.'} +

+
+
+ ) + } + + if (!isDeployed) return null + return ( - - {label} + + {needsRedeployment ? 'Update deployment' : 'Live'} ) } @@ -801,6 +896,8 @@ interface GeneralFooterProps { isUndeploying: boolean deployReadiness: DeployReadiness isDeploymentSettling: boolean + attemptStatus?: DeploymentAttemptStatus + attemptErrorMessage?: string | null onDeploy: () => Promise onRedeploy: () => Promise onUndeploy: () => void @@ -813,6 +910,8 @@ function GeneralFooter({ isUndeploying, deployReadiness, isDeploymentSettling, + attemptStatus, + attemptErrorMessage, onDeploy, onRedeploy, onUndeploy, @@ -828,7 +927,17 @@ function GeneralFooter({ if (!isDeployed) { return ( -
{blockedMessage}
+
+ + {blockedMessage && ( +
{blockedMessage}
+ )} +