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.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 38b24348167..b564490200f 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -72,7 +72,11 @@ describe('Chat Edit API Route', () => { }) mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) - mockPerformFullDeploy.mockResolvedValue({ success: true, version: 1 }) + mockPerformFullDeploy.mockResolvedValue({ + success: true, + version: 1, + latestDeploymentAttempt: { status: 'active' }, + }) mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) }) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 707051d7e9f..e505ef84e4f 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) { @@ -153,6 +152,19 @@ export const PATCH = withRouteHandler( : 500 return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status) } + /** + * Deploys settle asynchronously: `success` only admits the attempt. The + * chat record must not advance until cutover finished, otherwise a later + * preparation failure strands the chat on the previous version with no + * error. Mirrors the gate in performChatDeploy. + */ + if (deployResult.latestDeploymentAttempt?.status !== 'active') { + return createErrorResponse( + deployResult.warnings?.[0] ?? + 'Workflow deployment is still preparing. Retry the chat update after it becomes active.', + 409 + ) + } logger.info( `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` ) 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 cd8ec829cae..a67cb62ba37 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -251,7 +251,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, @@ -307,7 +307,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, @@ -361,7 +361,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) mockGenerateInternalToken.mockResolvedValueOnce('internal-token-owner-1') fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ output: { ok: true } }), { @@ -411,7 +411,9 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: workflowWorkspaceId }]) + .mockResolvedValueOnce([ + { workspaceId: workflowWorkspaceId, deploymentVersionId: 'deployment-1' }, + ]) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -442,7 +444,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) mockResolveBillingAttribution.mockResolvedValueOnce( createBillingAttribution('different-actor', 'ws-1') ) @@ -565,7 +567,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( new ReadableStream({ @@ -609,7 +611,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( new ReadableStream({ @@ -656,7 +658,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -689,6 +691,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 () => { @@ -703,7 +708,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ @@ -747,7 +752,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 })) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { @@ -780,7 +785,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ success: true, output: false }), { status: 200, @@ -817,7 +822,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), { status: 200, @@ -854,7 +859,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, userId: 'user-1', @@ -934,7 +939,7 @@ describe('MCP Serve Route', () => { }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) - .mockResolvedValueOnce([{ isDeployed: true, workspaceId: 'ws-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) fetchMock.mockImplementationOnce((_url, init: RequestInit) => { const signal = init.signal as AbortSignal return new Promise((_resolve, reject) => { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index a5650de1042..e342f35e343 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' @@ -751,14 +757,30 @@ async function handleToolsCall( } const [wf] = await db - .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId }) + .select({ + 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) if (abortedAfterWorkflowLookup) return abortedAfterWorkflowLookup - if (!wf?.isDeployed) { + /** + * Deployed means an active version snapshot exists — the legacy + * `workflow.isDeployed` flag is not consulted because when the two + * disagree the workflow cannot serve traffic anyway. Same definition as + * the deploy status GET route. + */ + if (!wf?.deploymentVersionId) { return NextResponse.json( createError(id, ErrorCode.InternalError, 'Workflow is not deployed'), { @@ -813,6 +835,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/tools/deployments/routes.test.ts b/apps/sim/app/api/tools/deployments/routes.test.ts index 63698c3a8b2..f0313a779b3 100644 --- a/apps/sim/app/api/tools/deployments/routes.test.ts +++ b/apps/sim/app/api/tools/deployments/routes.test.ts @@ -90,6 +90,11 @@ describe('POST /api/tools/deployments/deploy', () => { success: true, deployedAt: new Date('2026-06-12T00:00:00Z'), version: 4, + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, }) }) @@ -157,6 +162,11 @@ describe('POST /api/tools/deployments/deploy', () => { isDeployed: true, deployedAt: '2026-06-12T00:00:00.000Z', version: 4, + activeDeployment: { + deploymentVersionId: 'dv-4', + version: 4, + deployedAt: '2026-06-12T00:00:00.000Z', + }, warnings: [], }, }) @@ -236,6 +246,11 @@ describe('POST /api/tools/deployments/promote', () => { mockPerformActivateVersion.mockResolvedValue({ success: true, deployedAt: new Date('2026-06-12T00:00:00Z'), + activeDeployment: { + deploymentVersionId: 'dv-3', + version: 3, + deployedAt: '2026-06-12T00:00:00.000Z', + }, }) }) @@ -255,6 +270,11 @@ describe('POST /api/tools/deployments/promote', () => { isDeployed: true, deployedAt: '2026-06-12T00:00:00.000Z', version: 3, + activeDeployment: { + deploymentVersionId: 'dv-3', + version: 3, + deployedAt: '2026-06-12T00:00:00.000Z', + }, 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..4c7e1027161 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,17 @@ export const GET = withRouteHandler( return createErrorResponse(error.message, error.status) } - if (!workflowData.isDeployed) { + /** + * A workflow is deployed only when an active version snapshot exists — + * the same definition POST and the v1 routes use. The legacy + * `workflow.isDeployed` flag is deliberately not consulted: when it + * disagrees with the version table the workflow cannot actually serve + * traffic, so reporting it as live would be untruthful. + */ + const deploymentSummary = await getWorkflowDeploymentSummary(id) + const isDeployed = deploymentSummary.activeDeployment !== null + + if (!isDeployed) { logger.info(`[${requestId}] Workflow is not deployed: ${id}`) return createSuccessResponse({ isDeployed: false, @@ -52,10 +66,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 +86,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 +126,7 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId: actorUserId, - workflowName: workflowData!.name || undefined, requestId, - request, }) if (!result.success) { @@ -114,16 +136,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 +148,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 cbc3ea579c8..49f50b3ae21 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -81,6 +81,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' @@ -665,6 +666,7 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, + deploymentVersionId: admittedDeploymentVersionId, executionId: rawBodyExecutionId, triggerBlockId: parsedTriggerBlockId, startBlockId, @@ -673,6 +675,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) { @@ -803,6 +811,7 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, workflowStateOverride, + deploymentVersionId: _deploymentVersionId, triggerBlockId: _triggerBlockId, stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, @@ -1074,7 +1083,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..868bca830f8 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,20 @@ 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' + /** Exactly one parenthetical per row; selection already highlights the row. */ + const rowLabel = v.isActive + ? 'live' + : isOperationPending + ? 'pending' + : operationStatus === 'failed' + ? 'failed' + : isSelected + ? 'selected' + : null return (
{editingVersion === v.version ? ( {v.name && {v.name}} - {v.isActive && ( - (live) - )} - {isSelected && ( - (selected) + {rowLabel && ( + ({rowLabel}) )} )} 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 68178c9f3c4..115e1d7d59c 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 @@ -17,11 +17,13 @@ import { ModalTabsList, ModalTabsTrigger, Tooltip, + toast, } from '@sim/emcn' 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 { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -110,7 +112,6 @@ export function DeployModal({ const [activeTab, setActiveTab] = useState('general') const [chatSubmitting, setChatSubmitting] = useState(false) const [deployError, setDeployError] = useState(null) - const [deployWarnings, setDeployWarnings] = useState([]) const [isFinalizingDeploy, setIsFinalizingDeploy] = useState(false) const [isActivatingVersion, setIsActivatingVersion] = useState(false) const [isChatFormValid, setIsChatFormValid] = useState(false) @@ -149,7 +150,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, @@ -170,27 +171,26 @@ export function DeployModal({ const activateVersionMutation = useActivateDeploymentVersion() const versions = versionsData?.versions ?? [] + const deploymentAttemptStatus = deploymentInfoData?.latestDeploymentAttempt?.status + const attemptErrorMessage = + deploymentInfoData?.latestDeploymentAttempt?.error?.message ?? + (deploymentAttemptStatus === 'failed' ? 'Deployment preparation failed' : null) const isWorkflowStillActive = (targetWorkflowId: string) => { return useWorkflowRegistry.getState().activeWorkflowId === targetWorkflowId } - const syncDraftAfterDeploy = async (): Promise => { - if (!workflowId) return null + const syncDraftAfterDeploy = async (): Promise => { + if (!workflowId) return try { - const syncedActiveWorkflow = await syncLocalDraftFromServer(workflowId) - if (!syncedActiveWorkflow && isWorkflowStillActive(workflowId)) { - return 'Deployment succeeded, but local sync is still catching up. Refresh if the status looks stale.' - } - return null + await syncLocalDraftFromServer(workflowId) } catch (error) { - if (!isWorkflowStillActive(workflowId)) return null + if (!isWorkflowStillActive(workflowId)) return logger.warn('Workflow deployed, but local draft sync failed', { workflowId, error: toError(error).message, }) - return 'Deployment succeeded, but local sync failed. Refresh if the status looks stale.' } } @@ -241,7 +241,6 @@ export function DeployModal({ if (open && workflowId) { setActiveTab('general') setDeployError(null) - setDeployWarnings([]) setChatSuccess(false) const currentOutputs = selectedStreamingOutputsRef.current @@ -297,7 +296,6 @@ export function DeployModal({ deployActionIdRef.current = actionId setIsFinalizingDeploy(true) setDeployError(null) - setDeployWarnings([]) try { if (!(await deployReadiness.waitUntilReady())) { @@ -309,9 +307,9 @@ export function DeployModal({ try { const result = await deployMutation.mutateAsync({ workflowId }) - const syncWarning = await syncDraftAfterDeploy() - if (!isWorkflowStillActive(workflowId) || deployActionIdRef.current !== actionId) return - setDeployWarnings([...(result.warnings || []), ...(syncWarning ? [syncWarning] : [])]) + if (result.latestDeploymentAttempt?.status === 'active') { + await syncDraftAfterDeploy() + } } finally { if (deployActionIdRef.current === actionId) { setIsFinalizingDeploy(false) @@ -337,18 +335,14 @@ export function DeployModal({ activateVersionInFlightRef.current = true setIsActivatingVersion(true) - setDeployWarnings([]) + setDeployError(null) try { - const result = await activateVersionMutation.mutateAsync({ workflowId, version }) - if (!isWorkflowStillActive(workflowId)) return - if (result.warnings && result.warnings.length > 0) { - setDeployWarnings(result.warnings) - } + await activateVersionMutation.mutateAsync({ workflowId, version }) } catch (error) { if (!isWorkflowStillActive(workflowId)) return logger.error('Error promoting version:', { error }) - throw error + setDeployError(toError(error).message || `Failed to promote v${version} to live`) } finally { activateVersionInFlightRef.current = false setIsActivatingVersion(false) @@ -363,20 +357,23 @@ export function DeployModal({ return } - setDeployWarnings([]) - try { const result = await undeployMutation.mutateAsync({ workflowId: targetWorkflowId }) if (!isWorkflowStillActive(targetWorkflowId)) return setUndeployTargetWorkflowId(null) - if (result.warnings && result.warnings.length > 0) { - setDeployWarnings(result.warnings) - return - } onOpenChange(false) + /** + * Partial cleanup warnings (e.g. external subscription teardown left to + * background retries) surface as a toast so closing the modal does not + * silently swallow them. + */ + if (result.warnings?.length) { + toast.warning('Workflow undeployed', { description: result.warnings.join(' ') }) + } } catch (error: unknown) { if (!isWorkflowStillActive(targetWorkflowId)) return logger.error('Error undeploying workflow:', { error }) + toast.error('Failed to undeploy workflow', { description: toError(error).message }) } } @@ -388,7 +385,6 @@ export function DeployModal({ deployActionIdRef.current = actionId setIsFinalizingDeploy(true) setDeployError(null) - setDeployWarnings([]) try { if (!(await deployReadiness.waitUntilReady())) { @@ -414,9 +410,9 @@ export function DeployModal({ try { const result = await deployMutation.mutateAsync({ workflowId }) - const syncWarning = await syncDraftAfterDeploy() - if (!isWorkflowStillActive(workflowId) || deployActionIdRef.current !== actionId) return - setDeployWarnings([...(result.warnings || []), ...(syncWarning ? [syncWarning] : [])]) + if (result.latestDeploymentAttempt?.status === 'active') { + await syncDraftAfterDeploy() + } } finally { if (deployActionIdRef.current === actionId) { setIsFinalizingDeploy(false) @@ -442,7 +438,6 @@ export function DeployModal({ if (workflowId) releaseDeployAction(workflowId) setChatSubmitting(false) setDeployError(null) - setDeployWarnings([]) onOpenChange(false) } @@ -514,24 +509,11 @@ export function DeployModal({ Configure and manage workflow deployment settings including API, MCP, and chat options. - {(deployError || deployWarnings.length > 0) && ( -
- {deployError && ( - - {deployError} - - )} - {deployWarnings.map((warning) => ( - - {warning} - - ))} + {deployError && ( +
+ + {deployError} +
)} @@ -604,6 +586,8 @@ export function DeployModal({ isUndeploying={isUndeploying} deployReadiness={deployReadiness} isDeploymentSettling={isDeploymentSettling} + attemptStatus={deploymentAttemptStatus} + attemptErrorMessage={attemptErrorMessage} onDeploy={onDeploy} onRedeploy={handleRedeploy} onUndeploy={() => { @@ -746,15 +730,77 @@ 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 + * (labelled Retrying once an attempt has recorded a transient error), 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') { + const isRetrying = Boolean(attemptErrorMessage) + return ( + + + + {isRetrying ? 'Retrying' : 'Pending'} + + + + {isRetrying &&

{attemptErrorMessage}

} +

+ {isRetrying + ? isDeployed + ? 'Retrying automatically. The current version stays live until cutover completes.' + : 'Retrying automatically. The workflow goes live once activation completes.' + : 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'} ) } @@ -766,6 +812,8 @@ interface GeneralFooterProps { isUndeploying: boolean deployReadiness: DeployReadiness isDeploymentSettling: boolean + attemptStatus?: DeploymentAttemptStatus + attemptErrorMessage?: string | null onDeploy: () => Promise onRedeploy: () => Promise onUndeploy: () => void @@ -778,6 +826,8 @@ function GeneralFooter({ isUndeploying, deployReadiness, isDeploymentSettling, + attemptStatus, + attemptErrorMessage, onDeploy, onRedeploy, onUndeploy, @@ -788,12 +838,30 @@ function GeneralFooter({ deployReadiness.isBlocked && !deployReadiness.isSyncing && !isSubmitting && !isUndeploying ? deployReadiness.tooltip : null + const status = ( +
+ + {blockedMessage && ( +
+ {blockedMessage} +
+ )} +
+ ) const deployActionLoading = isSubmitting || isDeploymentSettling if (!isDeployed) { return ( -
{blockedMessage}
+ {status}