improvement(deployments): bugfixes for run-block, airtable + external sub management (#5680)

* improvement(webhooks): external subscription management

* ui/ux

* remove test file

* fix tests

* address comments

* address comments

* update to grain v2 api

* improvement(grain): hide auto-registered webhook URL on v2 triggers

* Revert "improvement(grain): hide auto-registered webhook URL on v2 triggers"

This reverts commit c89660cc3e.

* address comments

* address comments

* address rollback

* fix grain v2

* fix more comments
This commit is contained in:
Vikhyath Mondreti
2026-07-15 18:01:53 -07:00
committed by GitHub
parent 4d6301c900
commit 54b35a4f0e
148 changed files with 28375 additions and 1900 deletions
+1
View File
@@ -350,6 +350,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,
grain_v2: GrainIcon,
granola: GranolaIcon,
greenhouse: GreenhouseIcon,
greptile: GreptileIcon,
@@ -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
+137 -93
View File
@@ -6,7 +6,7 @@ description: Access meeting recordings, transcripts, and AI summaries
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="grain"
type="grain_v2"
color="#F6FAF9"
/>
@@ -58,6 +58,7 @@ List recordings from Grain with optional filters and pagination
| `includeHighlights` | boolean | No | Include highlights/clips in response |
| `includeParticipants` | boolean | No | Include participant list in response |
| `includeAiSummary` | boolean | No | Include AI-generated summary |
| `includeAiActionItems` | boolean | No | Include AI-detected action items |
#### Output
@@ -91,6 +92,7 @@ Get details of a single recording by ID
| `includeHighlights` | boolean | No | Include highlights/clips |
| `includeParticipants` | boolean | No | Include participant list |
| `includeAiSummary` | boolean | No | Include AI summary |
| `includeAiActionItems` | boolean | No | Include AI-detected action items |
| `includeCalendarEvent` | boolean | No | Include calendar event data |
| `includeHubspot` | boolean | No | Include HubSpot associations |
@@ -113,6 +115,7 @@ Get details of a single recording by ID
| `highlights` | array | Highlights \(if included\) |
| `participants` | array | Participants \(if included\) |
| `ai_summary` | object | AI summary text \(if included\) |
| `ai_action_items` | array | AI-detected action items with status, text, and assignee \(if included\) |
| `calendar_event` | object | Calendar event data \(if included\) |
| `hubspot` | object | HubSpot associations \(if included\) |
@@ -138,26 +141,6 @@ Get the full transcript of a recording
| ↳ `end` | number | End timestamp in ms |
| ↳ `text` | string | Transcript text |
### `grain_list_views`
List available Grain views for webhook subscriptions
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `typeFilter` | string | No | Optional view type filter: recordings, highlights, or stories |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `views` | array | Array of Grain views |
| ↳ `id` | string | View UUID |
| ↳ `name` | string | View name |
| ↳ `type` | string | View type: recordings, highlights, or stories |
### `grain_list_teams`
List all teams in the workspace
@@ -197,16 +180,16 @@ List all meeting types in the workspace
### `grain_create_hook`
Create a webhook to receive recording events
Create a webhook for a specific Grain event type (v2 API)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `hookUrl` | string | Yes | Webhook endpoint URL \(e.g., "https://example.com/webhooks/grain"\) |
| `viewId` | string | Yes | Grain view ID from GET /_/public-api/views |
| `actions` | array | No | Optional list of actions to subscribe to: added, updated, removed |
| `apiKey` | string | Yes | Grain API key \(Personal or Workspace Access Token\) |
| `hookUrl` | string | Yes | Webhook endpoint URL. Grain performs a reachability test on creation — the endpoint must respond 2xx. |
| `hookType` | string | Yes | Event type the hook subscribes to. One of: recording_added, recording_updated, recording_deleted, highlight_added, highlight_updated, highlight_deleted, story_added, story_updated, story_deleted, upload_status |
| `include` | json | No | Optional include object controlling payload richness. For recording hooks: \{"participants": true, "highlights": true, "ai_summary": true\}. For highlight hooks: \{"transcript": true, "speakers": true\}. |
#### Output
@@ -215,19 +198,21 @@ Create a webhook to receive recording events
| `id` | string | Hook UUID |
| `enabled` | boolean | Whether hook is active |
| `hook_url` | string | The webhook URL |
| `view_id` | string | Grain view ID for the webhook |
| `actions` | array | Configured actions for the webhook |
| `hook_type` | string | Event type the hook subscribes to |
| `include` | json | Include object the hook was created with |
| `inserted_at` | string | ISO8601 creation timestamp |
### `grain_list_hooks`
List all webhooks for the account
List webhooks for the account (v2 API)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `apiKey` | string | Yes | Grain API key \(Personal or Workspace Access Token\) |
| `hookType` | string | No | Only return hooks with this event type. One of: recording_added, recording_updated, recording_deleted, highlight_added, highlight_updated, highlight_deleted, story_added, story_updated, story_deleted, upload_status |
| `state` | string | No | Only return hooks that are "enabled" or "disabled" |
#### Output
@@ -237,19 +222,19 @@ List all webhooks for the account
| ↳ `id` | string | Hook UUID |
| ↳ `enabled` | boolean | Whether hook is active |
| ↳ `hook_url` | string | Webhook URL |
| ↳ `view_id` | string | Grain view ID |
| ↳ `actions` | array | Configured actions |
| ↳ `hook_type` | string | Event type the hook subscribes to |
| ↳ `include` | object | Include object the hook was created with |
| ↳ `inserted_at` | string | Creation timestamp |
### `grain_delete_hook`
Delete a webhook by ID
Delete a webhook by ID (v2 API)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `apiKey` | string | Yes | Grain API key \(Personal or Workspace Access Token\) |
| `hookId` | string | Yes | The hook UUID to delete \(e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890"\) |
#### Output
@@ -266,14 +251,13 @@ A **Trigger** is a block that starts a workflow when an event happens in this se
### Grain All Events
Trigger on all actions (added, updated, removed) in a Grain view
Trigger on every Grain event (recordings, highlights, stories, uploads)
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | The view determines which content type fires events \(recordings, highlights, or stories\). |
#### Output
@@ -286,16 +270,15 @@ Trigger on all actions (added, updated, removed) in a Grain view
---
### Grain Highlight Created
### Grain Highlight Added
Trigger workflow when a new highlight/clip is created in Grain
Trigger when a new highlight/clip is created in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | Required by Grain to create the webhook subscription. |
#### Output
@@ -317,18 +300,38 @@ Trigger workflow when a new highlight/clip is created in Grain
| ↳ `created_datetime` | string | ISO8601 creation timestamp |
---
### Grain Highlight Deleted
Trigger when a highlight/clip is deleted in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `type` | string | Event type \(e.g., recording_added\) |
| `user_id` | string | User UUID who triggered the event |
| `data` | object | Event data object \(recording, highlight, etc.\) |
---
### Grain Highlight Updated
Trigger workflow when a highlight/clip is updated in Grain
Trigger when a highlight/clip is updated in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | Required by Grain to create the webhook subscription. |
#### Output
@@ -352,60 +355,15 @@ Trigger workflow when a highlight/clip is updated in Grain
---
### Grain Item Added
### Grain Recording Added
Trigger when a new item is added to a Grain view (recording, highlight, or story)
Trigger when a new recording is added in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | The view determines which content type fires events \(recordings, highlights, or stories\). |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `type` | string | Event type \(e.g., recording_added\) |
| `user_id` | string | User UUID who triggered the event |
| `data` | object | Event data object \(recording, highlight, etc.\) |
---
### Grain Item Updated
Trigger when an item is updated in a Grain view (recording, highlight, or story)
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | The view determines which content type fires events \(recordings, highlights, or stories\). |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `type` | string | Event type \(e.g., recording_added\) |
| `user_id` | string | User UUID who triggered the event |
| `data` | object | Event data object \(recording, highlight, etc.\) |
---
### Grain Recording Created
Trigger workflow when a new recording is added in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | Required by Grain to create the webhook subscription. |
#### Output
@@ -428,18 +386,38 @@ Trigger workflow when a new recording is added in Grain
| ↳ `meeting_type` | object | Meeting type info with id, name, scope \(nullable\) |
---
### Grain Recording Deleted
Trigger when a recording is deleted in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `type` | string | Event type \(e.g., recording_added\) |
| `user_id` | string | User UUID who triggered the event |
| `data` | object | Event data object \(recording, highlight, etc.\) |
---
### Grain Recording Updated
Trigger workflow when a recording is updated in Grain
Trigger when a recording is updated in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | Required by Grain to create the webhook subscription. |
#### Output
@@ -464,16 +442,15 @@ Trigger workflow when a recording is updated in Grain
---
### Grain Story Created
### Grain Story Added
Trigger workflow when a new story is created in Grain
Trigger when a new story is created in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
| `viewId` | string | Yes | Required by Grain to create the webhook subscription. |
#### Output
@@ -487,3 +464,70 @@ Trigger workflow when a new story is created in Grain
| ↳ `url` | string | URL to view in Grain |
| ↳ `created_datetime` | string | ISO8601 creation timestamp |
---
### Grain Story Deleted
Trigger when a story is deleted in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `type` | string | Event type \(e.g., recording_added\) |
| `user_id` | string | User UUID who triggered the event |
| `data` | object | Event data object \(recording, highlight, etc.\) |
---
### Grain Story Updated
Trigger when a story is updated in Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `type` | string | Event type |
| `user_id` | string | User UUID who triggered the event |
| `data` | object | data output from the tool |
| ↳ `id` | string | Story UUID |
| ↳ `title` | string | Story title |
| ↳ `url` | string | URL to view in Grain |
| ↳ `created_datetime` | string | ISO8601 creation timestamp |
---
### Grain Upload Status
Trigger on progress updates for recordings uploaded to Grain
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Required to create the webhook in Grain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `type` | string | Event type \(e.g., recording_added\) |
| `user_id` | string | User UUID who triggered the event |
| `data` | object | Event data object \(recording, highlight, etc.\) |
@@ -26,9 +26,12 @@ const { mockCheckChatAccess } = vi.hoisted(() => ({
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockCheckNeedsRedeployment = workflowsApiUtilsMockFns.mockCheckNeedsRedeployment
const mockEncryptSecret = encryptionMockFns.mockEncryptSecret
const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy
const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUndeploy
const mockGetWorkflowDeploymentSummary =
workflowsOrchestrationMockFns.mockGetWorkflowDeploymentSummary
const mockNotifySocketDeploymentChanged =
workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged
@@ -72,7 +75,17 @@ describe('Chat Edit API Route', () => {
})
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
mockPerformFullDeploy.mockResolvedValue({ success: true, version: 1 })
mockGetWorkflowDeploymentSummary.mockResolvedValue({
activeDeployment: null,
latestDeploymentAttempt: null,
warnings: [],
})
mockCheckNeedsRedeployment.mockResolvedValue(false)
mockPerformFullDeploy.mockResolvedValue({
success: true,
version: 1,
latestDeploymentAttempt: { status: 'active' },
})
mockNotifySocketDeploymentChanged.mockResolvedValue(undefined)
})
@@ -200,6 +213,58 @@ describe('Chat Edit API Route', () => {
expect(data.message).toBe('Chat deployment updated successfully')
})
it('rejects the update without admitting a new deploy while an attempt is in flight', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } })
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { id: 'chat-123', identifier: 'test-chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
mockGetWorkflowDeploymentSummary.mockResolvedValue({
activeDeployment: null,
latestDeploymentAttempt: { status: 'preparing' },
warnings: [],
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(409)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
})
it('skips redeploying when the active version already matches the draft', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } })
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { id: 'chat-123', identifier: 'test-chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
mockGetWorkflowDeploymentSummary.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 3,
deployedAt: '2026-07-15T00:00:00.000Z',
},
latestDeploymentAttempt: { status: 'active' },
warnings: [],
})
mockCheckNeedsRedeployment.mockResolvedValue(false)
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
expect(dbChainMockFns.update).toHaveBeenCalled()
})
it('should handle identifier conflicts', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
+62 -21
View File
@@ -12,9 +12,17 @@ import { isDev } from '@/lib/core/config/env-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performChatUndeploy, performFullDeploy } from '@/lib/workflows/orchestration'
import {
getWorkflowDeploymentSummary,
performChatUndeploy,
performFullDeploy,
} from '@/lib/workflows/orchestration'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import {
checkNeedsRedeployment,
createErrorResponse,
createSuccessResponse,
} from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
export const maxDuration = 120
@@ -136,26 +144,59 @@ export const PATCH = withRouteHandler(
logger.info('Keeping existing password')
}
// Redeploy the workflow to ensure latest version is active
const deployResult = await performFullDeploy({
workflowId: existingChat[0].workflowId,
userId: session.user.id,
request,
})
if (!deployResult.success) {
logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`)
const status =
deployResult.errorCode === 'validation'
? 400
: deployResult.errorCode === 'not_found'
? 404
: 500
return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status)
/**
* A settings update only redeploys when the draft actually drifted from
* the active version, and never while another attempt is in flight —
* otherwise each blocked retry would admit a fresh deployment version
* on top of the pending one.
*/
const deploymentSummary = await getWorkflowDeploymentSummary(existingChat[0].workflowId)
const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status
if (attemptStatus === 'preparing' || attemptStatus === 'activating') {
return createErrorResponse(
'A workflow deployment is still preparing. Retry the chat update after it becomes active.',
409
)
}
const needsRedeploy =
!deploymentSummary.activeDeployment ||
(await checkNeedsRedeployment(existingChat[0].workflowId))
if (needsRedeploy) {
const deployResult = await performFullDeploy({
workflowId: existingChat[0].workflowId,
userId: session.user.id,
})
if (!deployResult.success) {
logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`)
const status =
deployResult.errorCode === 'validation'
? 400
: deployResult.errorCode === 'not_found'
? 404
: 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. A blocked retry lands in the in-flight gate
* above without admitting another version. Mirrors 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})`
)
}
logger.info(
`Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})`
)
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
@@ -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')
@@ -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<Uint8Array>({
@@ -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<Uint8Array>({
@@ -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<string, string>
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<Response>((_resolve, reject) => {
+26 -3
View File
@@ -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'),
@@ -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(),
@@ -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 ?? [],
},
})
@@ -53,9 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
workflowId,
version,
userId: auth.userId,
workflow: access.workflow as Record<string, unknown>,
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 ?? [],
},
})
@@ -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: [],
})
})
-12
View File
@@ -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
// =============================================================================
@@ -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,
}
@@ -40,9 +40,7 @@ export const POST = withRouteHandler(
workflowId,
version: versionNum,
userId: workflowRecord.userId,
workflow: workflowRecord as Record<string, unknown>,
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(
@@ -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 () => {
@@ -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,
@@ -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' }),
})
})
@@ -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<string, unknown>,
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,
+36 -18
View File
@@ -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) {
@@ -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<string, unknown>,
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 }),
})
@@ -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)
@@ -63,8 +63,14 @@ export const POST = withRouteHandler(
await recordBackgroundWork(db, {
workspaceId: id,
kind: 'fork_rollback',
status: result.skipped > 0 ? 'completed_with_warnings' : 'completed',
message: `Undid the last sync from "${otherName}"`,
status:
result.skipped > 0 || result.pendingActivations.length > 0
? 'completed_with_warnings'
: 'completed',
message:
result.pendingActivations.length > 0
? `Undid the last sync from "${otherName}" — ${result.pendingActivations.length} deployment(s) still activating`
: `Undid the last sync from "${otherName}"`,
metadata: {
actorName: session.user.name ?? undefined,
otherWorkspaceId,
@@ -73,6 +79,7 @@ export const POST = withRouteHandler(
removed: result.archived,
unarchived: result.unarchived,
skipped: result.skipped,
pendingActivations: result.pendingActivations.length,
},
}).catch((error) =>
logger.error(`[${requestId}] Failed to record rollback activity`, {
@@ -207,6 +207,20 @@ export function Versions({
<div className='bg-[var(--surface-2)]'>
{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 (
<div
@@ -233,9 +247,23 @@ export function Versions({
<div
className={cn(
'size-[6px] shrink-0 rounded-xs',
v.isActive ? 'bg-[var(--indicator-active)]' : 'bg-[var(--indicator-inactive)]'
v.isActive
? 'bg-[var(--indicator-active)]'
: isOperationPending
? 'bg-amber-400'
: operationStatus === 'failed'
? 'bg-red-400'
: 'bg-[var(--indicator-inactive)]'
)}
title={v.isActive ? 'Live' : 'Inactive'}
title={
v.isActive
? 'Live'
: isOperationPending
? 'Pending'
: operationStatus === 'failed'
? 'Failed'
: 'Inactive'
}
/>
{editingVersion === v.version ? (
<Input
@@ -269,11 +297,8 @@ export function Versions({
v{v.version}
</span>
{v.name && <span className='truncate'>{v.name}</span>}
{v.isActive && (
<span className='shrink-0 text-[var(--text-tertiary)]'>(live)</span>
)}
{isSelected && (
<span className='shrink-0 text-[var(--text-tertiary)]'>(selected)</span>
{rowLabel && (
<span className='shrink-0 text-[var(--text-tertiary)]'>({rowLabel})</span>
)}
</span>
)}
@@ -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<TabView>('general')
const [chatSubmitting, setChatSubmitting] = useState(false)
const [deployError, setDeployError] = useState<string | null>(null)
const [deployWarnings, setDeployWarnings] = useState<string[]>([])
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,30 +171,44 @@ 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<string | null> => {
if (!workflowId) return null
const syncDraftAfterDeploy = async (): Promise<void> => {
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.'
}
}
/**
* Post-activation warnings (dead-lettered or still-queued side effects)
* arrive with an `active` attempt, so the Live badge gives no signal —
* surface them as a toast. Pending/failed attempts are excluded: the
* status badge already covers those.
*/
const toastPostActivationWarnings = (
title: string,
result: { latestDeploymentAttempt?: { status: string } | null; warnings?: string[] }
) => {
if (result.latestDeploymentAttempt?.status !== 'active') return
if (!result.warnings?.length) return
toast.warning(title, { description: result.warnings.join(' ') })
}
useEffect(() => {
deployActionIdRef.current += 1
setIsFinalizingDeploy(false)
@@ -241,7 +256,6 @@ export function DeployModal({
if (open && workflowId) {
setActiveTab('general')
setDeployError(null)
setDeployWarnings([])
setChatSuccess(false)
const currentOutputs = selectedStreamingOutputsRef.current
@@ -297,7 +311,6 @@ export function DeployModal({
deployActionIdRef.current = actionId
setIsFinalizingDeploy(true)
setDeployError(null)
setDeployWarnings([])
try {
if (!(await deployReadiness.waitUntilReady())) {
@@ -309,9 +322,12 @@ 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()
}
if (isWorkflowStillActive(workflowId)) {
toastPostActivationWarnings('Workflow deployed', result)
}
} finally {
if (deployActionIdRef.current === actionId) {
setIsFinalizingDeploy(false)
@@ -337,18 +353,17 @@ 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)
if (isWorkflowStillActive(workflowId)) {
toastPostActivationWarnings(`Promoted v${version} to live`, result)
}
} 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 +378,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 +406,6 @@ export function DeployModal({
deployActionIdRef.current = actionId
setIsFinalizingDeploy(true)
setDeployError(null)
setDeployWarnings([])
try {
if (!(await deployReadiness.waitUntilReady())) {
@@ -414,9 +431,12 @@ 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()
}
if (isWorkflowStillActive(workflowId)) {
toastPostActivationWarnings('Workflow redeployed', result)
}
} finally {
if (deployActionIdRef.current === actionId) {
setIsFinalizingDeploy(false)
@@ -442,7 +462,6 @@ export function DeployModal({
if (workflowId) releaseDeployAction(workflowId)
setChatSubmitting(false)
setDeployError(null)
setDeployWarnings([])
onOpenChange(false)
}
@@ -514,24 +533,11 @@ export function DeployModal({
Configure and manage workflow deployment settings including API, MCP, and chat
options.
</ModalDescription>
{(deployError || deployWarnings.length > 0) && (
<div className='mb-3 flex flex-col gap-2'>
{deployError && (
<Badge variant='red' size='lg' dot className='max-w-full truncate'>
{deployError}
</Badge>
)}
{deployWarnings.map((warning) => (
<Badge
key={warning}
variant='amber'
size='lg'
dot
className='max-w-full truncate'
>
{warning}
</Badge>
))}
{deployError && (
<div className='mb-3' role='alert'>
<Badge variant='red' size='lg' dot className='max-w-full truncate'>
{deployError}
</Badge>
</div>
)}
<ModalTabsContent value='general'>
@@ -604,6 +610,8 @@ export function DeployModal({
isUndeploying={isUndeploying}
deployReadiness={deployReadiness}
isDeploymentSettling={isDeploymentSettling}
attemptStatus={deploymentAttemptStatus}
attemptErrorMessage={attemptErrorMessage}
onDeploy={onDeploy}
onRedeploy={handleRedeploy}
onUndeploy={() => {
@@ -746,15 +754,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 (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge variant='amber' size='lg' dot className='cursor-default'>
{isRetrying ? 'Retrying' : 'Pending'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[320px]'>
{isRetrying && <p className='text-caption'>{attemptErrorMessage}</p>}
<p className='text-caption'>
{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.'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
if (attemptStatus === 'failed') {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge variant='red' size='lg' dot className='cursor-default'>
Failed
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[320px]'>
<p className='text-caption'>{attemptErrorMessage || 'Deployment preparation failed.'}</p>
<p className='text-caption'>
{isDeployed
? 'The previously deployed version is still live.'
: 'The workflow remains undeployed.'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
if (!isDeployed) return null
return (
<Badge variant={isWarning ? 'amber' : 'green'} size='lg' dot>
{label}
<Badge variant={needsRedeployment ? 'amber' : 'green'} size='lg' dot>
{needsRedeployment ? 'Update deployment' : 'Live'}
</Badge>
)
}
@@ -766,6 +836,8 @@ interface GeneralFooterProps {
isUndeploying: boolean
deployReadiness: DeployReadiness
isDeploymentSettling: boolean
attemptStatus?: DeploymentAttemptStatus
attemptErrorMessage?: string | null
onDeploy: () => Promise<void>
onRedeploy: () => Promise<void>
onUndeploy: () => void
@@ -778,6 +850,8 @@ function GeneralFooter({
isUndeploying,
deployReadiness,
isDeploymentSettling,
attemptStatus,
attemptErrorMessage,
onDeploy,
onRedeploy,
onUndeploy,
@@ -788,12 +862,30 @@ function GeneralFooter({
deployReadiness.isBlocked && !deployReadiness.isSyncing && !isSubmitting && !isUndeploying
? deployReadiness.tooltip
: null
const status = (
<div className='flex min-w-0 flex-col gap-1'>
<StatusBadge
isDeployed={Boolean(isDeployed)}
needsRedeployment={needsRedeployment}
attemptStatus={attemptStatus}
attemptErrorMessage={attemptErrorMessage}
/>
{blockedMessage && (
<div
className='max-w-[300px] truncate text-[var(--text-muted)] text-xs'
title={blockedMessage}
>
{blockedMessage}
</div>
)}
</div>
)
const deployActionLoading = isSubmitting || isDeploymentSettling
if (!isDeployed) {
return (
<ModalFooter className='items-center justify-between'>
<div className='max-w-[260px] text-[var(--text-muted)] text-xs'>{blockedMessage}</div>
{status}
<div className='flex items-center gap-2'>
<Button variant='tertiary' onClick={onDeploy} disabled={isDeployBlocked}>
{deployActionLoading && <Loader className='mr-1.5 size-3.5' animate />}
@@ -806,12 +898,7 @@ function GeneralFooter({
return (
<ModalFooter className='items-center justify-between'>
<div className='flex min-w-0 flex-col gap-1'>
<StatusBadge isWarning={needsRedeployment} />
{blockedMessage && (
<div className='max-w-[300px] text-[var(--text-muted)] text-xs'>{blockedMessage}</div>
)}
</div>
{status}
<div className='flex items-center gap-2'>
<Button variant='default' onClick={onUndeploy} disabled={isUndeploying || isSubmitting}>
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
@@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import Editor from 'react-simple-code-editor'
import { isElseConditionTitle } from '@/lib/workflows/conditions'
import {
isLikelyReferenceSegment,
SYSTEM_REFERENCE_PREFIXES,
@@ -736,7 +737,7 @@ export function ConditionInput({
if (isPreview || disabled) return
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
if (!isRouterMode && conditionalBlocks[blockIndex]?.title === 'else') return
if (!isRouterMode && isElseConditionTitle(conditionalBlocks[blockIndex]?.title)) return
const newBlockId = isRouterMode
? generateStableId(blockId, `route-${Date.now()}`)
@@ -793,7 +794,7 @@ export function ConditionInput({
const blockIndex = conditionalBlocks.findIndex((block) => block.id === id)
if (blockIndex === -1) return
if (conditionalBlocks[blockIndex]?.title === 'else') return
if (isElseConditionTitle(conditionalBlocks[blockIndex]?.title)) return
if (
(direction === 'up' && blockIndex === 0) ||
@@ -804,7 +805,7 @@ export function ConditionInput({
const newBlocks = [...conditionalBlocks]
const targetIndex = direction === 'up' ? blockIndex - 1 : blockIndex + 1
if (direction === 'down' && newBlocks[targetIndex]?.title === 'else') return
if (direction === 'down' && isElseConditionTitle(newBlocks[targetIndex]?.title)) return
;[newBlocks[blockIndex], newBlocks[targetIndex]] = [
newBlocks[targetIndex],
@@ -950,7 +951,7 @@ export function ConditionInput({
'flex items-center justify-between overflow-hidden bg-transparent px-2.5 py-[5px]',
isRouterMode
? 'rounded-t-[4px] border-[var(--border-1)] border-b'
: block.title === 'else'
: isElseConditionTitle(block.title)
? 'rounded-sm border-0'
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
)}
@@ -964,7 +965,11 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => addBlock(block.id)}
disabled={isPreview || disabled || (!isRouterMode && block.title === 'else')}
disabled={
isPreview ||
disabled ||
(!isRouterMode && isElseConditionTitle(block.title))
}
className='h-auto p-0'
>
<Plus className='size-[14px]' />
@@ -983,7 +988,7 @@ export function ConditionInput({
isPreview ||
index === 0 ||
disabled ||
(!isRouterMode && block.title === 'else')
(!isRouterMode && isElseConditionTitle(block.title))
}
className='h-auto p-0'
>
@@ -1003,8 +1008,9 @@ export function ConditionInput({
isPreview ||
disabled ||
index === conditionalBlocks.length - 1 ||
(!isRouterMode && conditionalBlocks[index + 1]?.title === 'else') ||
(!isRouterMode && block.title === 'else')
(!isRouterMode &&
isElseConditionTitle(conditionalBlocks[index + 1]?.title)) ||
(!isRouterMode && isElseConditionTitle(block.title))
}
className='h-auto p-0'
>
@@ -1205,7 +1211,7 @@ export function ConditionInput({
{/* Condition mode: show code editor */}
{!isRouterMode &&
block.title !== 'else' &&
!isElseConditionTitle(block.title) &&
(() => {
const blockLineCount = block.value.split('\n').length
const blockGutterWidth = calculateGutterWidth(blockLineCount)
@@ -115,6 +115,7 @@ describe('async preprocessing correlation threading', () => {
status: 'active',
archivedAt: null,
lastQueuedAt: new Date('2025-01-01T00:00:00.000Z'),
deploymentOperationId: null,
},
])
mockLoadDeployedWorkflowState.mockResolvedValue({
+18 -5
View File
@@ -319,17 +319,23 @@ async function isScheduleDeploymentVersionActive(
async function isScheduleClaimCurrent(
scheduleId: string,
claimedAt: Date | null
claimedAt: Date | null,
deploymentOperationId?: string
): Promise<boolean> {
if (!claimedAt) return true
if (!claimedAt && !deploymentOperationId) return true
const [scheduleRecord] = await db
.select({ lastQueuedAt: workflowSchedule.lastQueuedAt })
.select({
lastQueuedAt: workflowSchedule.lastQueuedAt,
deploymentOperationId: workflowSchedule.deploymentOperationId,
})
.from(workflowSchedule)
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
.limit(1)
return scheduleRecord?.lastQueuedAt?.getTime() === claimedAt.getTime()
if (!scheduleRecord) return false
if (claimedAt && scheduleRecord.lastQueuedAt?.getTime() !== claimedAt.getTime()) return false
return scheduleRecord.deploymentOperationId === (deploymentOperationId ?? null)
}
async function runWorkflowExecution({
@@ -455,7 +461,13 @@ async function runWorkflowExecution({
}
const claimedAt = getScheduleClaimedAt(payload)
if (!(await isScheduleClaimCurrent(payload.scheduleId, claimedAt))) {
if (
!(await isScheduleClaimCurrent(
payload.scheduleId,
claimedAt,
payload.deploymentOperationId
))
) {
logger.info(
`[${requestId}] Schedule claim changed before workflow core started, skipping`,
{
@@ -569,6 +581,7 @@ export type ScheduleExecutionPayload = {
correlation?: AsyncExecutionCorrelation
blockId?: string
deploymentVersionId?: string
deploymentOperationId?: string
cronExpression?: string
timezone?: string
lastRanAt?: string
@@ -9,6 +9,7 @@ import {
} from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, asc, eq, gt, isNull, like, or } from 'drizzle-orm'
import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate'
const logger = createLogger('TikTokWebhookTargets')
const ACCOUNT_ID_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -70,8 +71,7 @@ export async function findTikTokWebhookTargetPage(
and(
eq(webhookCredentialIdExpression(webhook.providerConfig), credential.id),
eq(webhook.provider, 'tiktok'),
eq(webhook.isActive, true),
isNull(webhook.archivedAt)
deliverableWebhookPredicate(webhook)
)
)
.innerJoin(
+68 -1
View File
@@ -20,6 +20,7 @@ const {
mockGetActiveSpan,
mockExecuteWithIdempotency,
mockReleaseExecutionSlot,
mockLoadDeploymentVersionState,
} = vi.hoisted(() => ({
mockResolveWebhookRecordProviderConfig: vi.fn(),
mockExecuteWorkflowCore: vi.fn(),
@@ -28,6 +29,15 @@ const {
mockGetActiveSpan: vi.fn(),
mockExecuteWithIdempotency: vi.fn(),
mockReleaseExecutionSlot: vi.fn(),
mockLoadDeploymentVersionState: vi.fn(
async (_workflowId: string, deploymentVersionId: string) => ({
blocks: {},
edges: [],
loops: {},
parallels: {},
deploymentVersionId,
})
),
}))
vi.mock('@opentelemetry/api', () => ({
@@ -66,6 +76,7 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
parallels: {},
deploymentVersionId: 'deployment-1',
})),
loadWorkflowDeploymentVersionState: mockLoadDeploymentVersionState,
}))
vi.mock('@/lib/webhooks/providers', () => ({
@@ -206,7 +217,13 @@ describe('executeWebhookJob fault vs error handling', () => {
success: true,
actorUserId: 'user-1',
billingAttribution,
workflowRecord: { workspaceId: 'workspace-1', userId: 'user-1', variables: {} },
workflowRecord: {
workspaceId: 'workspace-1',
userId: 'user-1',
variables: {},
isDeployed: true,
archivedAt: null,
},
executionTimeout: { async: 120_000 },
})
mockResolveWebhookRecordProviderConfig.mockImplementation(async (record) => record)
@@ -248,6 +265,56 @@ describe('executeWebhookJob fault vs error handling', () => {
expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalled()
})
it('executes against the deployment version admitted by webhook ingress', async () => {
mockExecuteWorkflowCore.mockResolvedValue({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
await executeWebhookJob({
...payload,
deploymentVersionId: 'deployment-admitted',
})
expect(mockLoadDeploymentVersionState).toHaveBeenCalledWith(
'workflow-1',
'deployment-admitted',
'workspace-1'
)
})
it('acknowledges and skips queued webhook work after the workflow is undeployed', async () => {
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({
success: true,
actorUserId: 'user-1',
billingAttribution,
workflowRecord: {
workspaceId: 'workspace-1',
userId: 'user-1',
variables: {},
isDeployed: false,
archivedAt: null,
},
executionTimeout: { async: 120_000 },
})
const result = await executeWebhookJob(payload)
expect(result).toMatchObject({ skipped: true, success: false, workflowId: 'workflow-1' })
expect(mockExecuteWorkflowCore).not.toHaveBeenCalled()
expect(mockReleaseExecutionSlot).toHaveBeenCalled()
})
it('releases the reservation when idempotency returns a cached result', async () => {
const cachedResult = {
success: true,
+35 -2
View File
@@ -29,7 +29,10 @@ import {
wasExecutionFinalizedByCore,
} from '@/lib/workflows/executor/execution-core'
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import {
loadDeployedWorkflowState,
loadWorkflowDeploymentVersionState,
} from '@/lib/workflows/persistence/utils'
import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { WEBHOOK_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits'
import { getBlock } from '@/blocks'
@@ -241,6 +244,8 @@ export type WebhookExecutionPayload = {
headers: Record<string, string>
path: string
blockId?: string
/** Immutable deployment admitted by webhook ingress; absent on legacy queued jobs. */
deploymentVersionId?: string
workspaceId: string
credentialId?: string
/** Epoch ms when the webhook HTTP request was first received (for dispatch-latency metrics). */
@@ -407,6 +412,27 @@ async function executeWebhookJobInternal(
if (!workflowRecord) {
throw new Error(`Workflow ${payload.workflowId} not found during preprocessing`)
}
if (!workflowRecord.isDeployed || workflowRecord.archivedAt) {
/**
* A queued delivery racing an undeploy/archive is an expected terminal
* condition, not a job fault: acknowledge and skip so workers do not
* record a failed job (or burn retries) for work that must never run.
*/
logger.info(`[${requestId}] Skipping webhook execution for undeployed workflow`, {
workflowId: payload.workflowId,
archived: Boolean(workflowRecord.archivedAt),
})
await releaseExecutionSlot(executionId)
return {
success: false,
skipped: true,
workflowId: payload.workflowId,
executionId,
output: {},
executedAt: new Date().toISOString(),
provider: payload.provider,
}
}
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
@@ -420,8 +446,15 @@ async function executeWebhookJobInternal(
let deploymentVersionId: string | undefined
try {
const workflowStatePromise = payload.deploymentVersionId
? loadWorkflowDeploymentVersionState(
payload.workflowId,
payload.deploymentVersionId,
workspaceId
)
: loadDeployedWorkflowState(payload.workflowId, workspaceId)
const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([
loadDeployedWorkflowState(payload.workflowId, workspaceId),
workflowStatePromise,
db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1),
payload.credentialId
? resolveCredentialAccountUserId(payload.credentialId)
+109
View File
@@ -0,0 +1,109 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/triggers', () => ({
getTrigger: () => ({ subBlocks: [] }),
}))
import { AirtableBlock } from '@/blocks/blocks/airtable'
const nestedFields = {
StringBoolean: 'false',
StringNumber: '42',
Boolean: true,
Number: 42,
Nested: {
values: ['true', false, '001'],
},
}
const records = [{ fields: nestedFields }]
const updateRecords = [{ id: 'rec123', fields: nestedFields }]
const baseParams = {
oauthCredential: 'credential',
baseId: 'app123',
tableId: 'tbl123',
}
const operationCases = [
{
operation: 'create',
params: { records: JSON.stringify(records) },
payload: { records },
},
{
operation: 'update',
params: { recordId: 'rec123', fields: JSON.stringify(nestedFields) },
payload: { fields: nestedFields },
},
{
operation: 'updateMultiple',
params: { records: JSON.stringify(updateRecords) },
payload: { records: updateRecords },
},
{
operation: 'upsert',
params: {
records: JSON.stringify(records),
fieldsToMergeOn: JSON.stringify(['External ID']),
},
payload: { records, fieldsToMergeOn: ['External ID'] },
},
] as const
describe('AirtableBlock typecast', () => {
const buildParams = AirtableBlock.tools.config.params!
it('exposes typecast as an advanced switch for all write operations', () => {
expect(AirtableBlock.subBlocks.find(({ id }) => id === 'typecast')).toMatchObject({
type: 'switch',
mode: 'advanced',
condition: {
field: 'operation',
value: ['create', 'update', 'updateMultiple', 'upsert'],
},
})
})
describe.each(operationCases)('$operation params', ({ operation, params, payload }) => {
it('omits typecast when unset and preserves nested values', () => {
expect(buildParams({ ...baseParams, operation, ...params })).toMatchObject(payload)
expect(buildParams({ ...baseParams, operation, ...params })).not.toHaveProperty('typecast')
})
it.each([
{ supplied: true, expected: true },
{ supplied: false, expected: false },
{ supplied: 'true', expected: true },
{ supplied: 'false', expected: false },
])('coerces only typecast $supplied to $expected', ({ supplied, expected }) => {
expect(
buildParams({
...baseParams,
operation,
...params,
typecast: supplied,
})
).toMatchObject({
...payload,
typecast: expected,
})
})
})
it('does not pass typecast to read or delete operations', () => {
expect(buildParams({ ...baseParams, operation: 'list', typecast: 'true' })).not.toHaveProperty(
'typecast'
)
expect(
buildParams({
...baseParams,
operation: 'delete',
recordIds: JSON.stringify(['rec123']),
typecast: 'true',
})
).not.toHaveProperty('typecast')
})
})
+7 -5
View File
@@ -268,7 +268,7 @@ Return ONLY the valid JSON array of field name strings - no explanations, no mar
id: 'typecast',
title: 'Typecast',
type: 'switch',
condition: { field: 'operation', value: 'upsert' },
condition: { field: 'operation', value: ['create', 'update', 'updateMultiple', 'upsert'] },
mode: 'advanced',
},
{
@@ -369,22 +369,24 @@ Return ONLY the valid JSON array of record ID strings - no explanations, no mark
credential: oauthCredential,
...rest,
}
const typecastParams =
typecast != null ? { typecast: typecast === true || typecast === 'true' } : {}
switch (params.operation) {
case 'create':
case 'updateMultiple':
return { ...baseParams, records: parsedRecords }
return { ...baseParams, records: parsedRecords, ...typecastParams }
case 'upsert':
return {
...baseParams,
records: parsedRecords,
fieldsToMergeOn: parsedFieldsToMergeOn,
...(typecast != null ? { typecast: typecast === true || typecast === 'true' } : {}),
...typecastParams,
}
case 'delete':
return { ...baseParams, recordIds: parsedRecordIds }
case 'update':
return { ...baseParams, fields: parsedFields }
return { ...baseParams, fields: parsedFields, ...typecastParams }
default:
return baseParams // No JSON parsing needed for list/get
}
@@ -403,7 +405,7 @@ Return ONLY the valid JSON array of record ID strings - no explanations, no mark
records: { type: 'json', description: 'Record data array' }, // Required for create/updateMultiple/upsert
fields: { type: 'json', description: 'Field data object' }, // Required for update single
fieldsToMergeOn: { type: 'json', description: 'Field names to match records on' }, // Required for upsert
typecast: { type: 'boolean', description: 'Auto-convert string values to field types' }, // Optional for upsert
typecast: { type: 'boolean', description: 'Auto-convert string values to field types' },
recordIds: { type: 'json', description: 'Record IDs to delete' }, // Required for delete
},
// Output structure depends on the operation, covered by AirtableResponse union type
+297 -2
View File
@@ -2,7 +2,21 @@ import { GrainIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { getTrigger } from '@/triggers'
import { grainTriggerOptions } from '@/triggers/grain/utils'
import { GRAIN_HOOK_TYPE_OPTIONS, grainTriggerOptions } from '@/triggers/grain/utils'
const GRAIN_V2_TRIGGER_IDS = [
'grain_recording_added_v2',
'grain_recording_updated_v2',
'grain_recording_deleted_v2',
'grain_highlight_added_v2',
'grain_highlight_updated_v2',
'grain_highlight_deleted_v2',
'grain_story_added_v2',
'grain_story_updated_v2',
'grain_story_deleted_v2',
'grain_upload_status_v2',
'grain_all_events_v2',
] as const
export const GrainBlock: BlockConfig = {
type: 'grain',
@@ -10,6 +24,9 @@ export const GrainBlock: BlockConfig = {
description: 'Access meeting recordings, transcripts, and AI summaries',
authMode: AuthMode.ApiKey,
triggerAllowed: true,
// Superseded by grain_v2 (Grain API v1 sunsets 2026-09-07); existing blocks
// keep rendering, new blocks come from the v2 entry.
hideFromToolbar: true,
longDescription:
'Integrate Grain into your workflow. Access meeting recordings, transcripts, highlights, and AI-generated summaries. Can also trigger workflows based on Grain webhook events.',
category: 'tools',
@@ -208,6 +225,16 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
value: ['grain_list_recordings', 'grain_get_recording'],
},
},
// Include AI action items
{
id: 'includeAiActionItems',
title: 'Include AI Action Items',
type: 'switch',
condition: {
field: 'operation',
value: ['grain_list_recordings', 'grain_get_recording'],
},
},
{
id: 'viewId',
title: 'View ID',
@@ -316,6 +343,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
includeHighlights: params.includeHighlights || false,
includeParticipants: params.includeParticipants || false,
includeAiSummary: params.includeAiSummary || false,
includeAiActionItems: params.includeAiActionItems || false,
}
case 'grain_get_recording':
@@ -328,6 +356,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
includeHighlights: params.includeHighlights || false,
includeParticipants: params.includeParticipants || false,
includeAiSummary: params.includeAiSummary || false,
includeAiActionItems: params.includeAiActionItems || false,
includeCalendarEvent: params.includeCalendarEvent || false,
includeHubspot: params.includeHubspot || false,
}
@@ -399,6 +428,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
includeHighlights: { type: 'boolean', description: 'Include highlights/clips in response' },
includeParticipants: { type: 'boolean', description: 'Include participant list in response' },
includeAiSummary: { type: 'boolean', description: 'Include AI-generated summary' },
includeAiActionItems: { type: 'boolean', description: 'Include AI-detected action items' },
includeCalendarEvent: { type: 'boolean', description: 'Include calendar event data' },
includeHubspot: { type: 'boolean', description: 'Include HubSpot associations' },
hookUrl: { type: 'string', description: 'Webhook endpoint URL' },
@@ -462,6 +492,255 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
},
}
/**
* grain_v2 — the go-forward Grain block on the v2 Public API (v1 sunsets
* 2026-09-07). Data operations are identical to v1 (already on v2 endpoints);
* the webhook operations move to the v2 hooks API (hook_type-scoped, no
* views), and the trigger set is replaced by the event-type-based
* `grain_events` trigger.
*/
export const GrainV2Block: BlockConfig = {
...GrainBlock,
type: 'grain_v2',
hideFromToolbar: false,
subBlocks: [
...GrainBlock.subBlocks.flatMap((sb) => {
// Drop v1 trigger subblocks (matched per source trigger), the v1
// trigger picker, and the v1-only view-based fields/operations.
if (
sb.mode === 'trigger' ||
sb.id === 'selectedTriggerId' ||
sb.id === 'viewId' ||
sb.id === 'hookUrl' ||
sb.id === 'hookId'
) {
return []
}
if (sb.id === 'operation') {
return [
{
...sb,
options: [
{ label: 'List Recordings', id: 'grain_list_recordings' },
{ label: 'Get Recording', id: 'grain_get_recording' },
{ label: 'Get Transcript', id: 'grain_get_transcript' },
{ label: 'List Teams', id: 'grain_list_teams' },
{ label: 'List Meeting Types', id: 'grain_list_meeting_types' },
{ label: 'Create Webhook', id: 'grain_create_hook_v2' },
{ label: 'List Webhooks', id: 'grain_list_hooks_v2' },
{ label: 'Delete Webhook', id: 'grain_delete_hook_v2' },
],
},
]
}
// Pagination token: rarely hand-entered, belongs under Advanced.
if (sb.id === 'cursor') {
return [{ ...sb, mode: 'advanced' as const }]
}
return [sb]
}),
{
id: 'hookUrl',
title: 'Webhook URL',
type: 'short-input',
placeholder: 'Enter webhook endpoint URL',
required: true,
condition: {
field: 'operation',
value: ['grain_create_hook_v2'],
},
},
{
id: 'hookType',
title: 'Event Type',
type: 'dropdown',
options: GRAIN_HOOK_TYPE_OPTIONS,
value: () => 'recording_added',
required: true,
condition: {
field: 'operation',
value: ['grain_create_hook_v2'],
},
},
{
id: 'hookInclude',
title: 'Include Options',
type: 'code',
language: 'json',
placeholder:
'{"participants": true, "highlights": true, "ai_summary": true} (recording hooks) or {"transcript": true, "speakers": true} (highlight hooks)',
mode: 'advanced',
condition: {
field: 'operation',
value: ['grain_create_hook_v2'],
},
},
{
id: 'hookTypeFilter',
title: 'Event Type Filter',
type: 'dropdown',
options: [{ label: 'All', id: '' }, ...GRAIN_HOOK_TYPE_OPTIONS],
value: () => '',
mode: 'advanced',
condition: {
field: 'operation',
value: ['grain_list_hooks_v2'],
},
},
{
id: 'hookState',
title: 'State Filter',
type: 'dropdown',
options: [
{ label: 'All', id: '' },
{ label: 'Enabled', id: 'enabled' },
{ label: 'Disabled', id: 'disabled' },
],
value: () => '',
mode: 'advanced',
condition: {
field: 'operation',
value: ['grain_list_hooks_v2'],
},
},
{
id: 'hookId',
title: 'Webhook ID',
type: 'short-input',
placeholder: 'Enter webhook UUID to delete',
required: true,
condition: {
field: 'operation',
value: ['grain_delete_hook_v2'],
},
},
...GRAIN_V2_TRIGGER_IDS.flatMap((triggerId) => getTrigger(triggerId).subBlocks),
],
tools: {
access: [
'grain_list_recordings',
'grain_get_recording',
'grain_get_transcript',
'grain_list_teams',
'grain_list_meeting_types',
'grain_create_hook_v2',
'grain_list_hooks_v2',
'grain_delete_hook_v2',
],
config: {
tool: (params) => {
return params.operation || 'grain_list_recordings'
},
params: (params) => {
const baseParams: Record<string, unknown> = {
apiKey: params.apiKey,
}
switch (params.operation) {
case 'grain_create_hook_v2': {
if (!params.hookUrl?.trim()) {
throw new Error('Webhook URL is required.')
}
if (!params.hookType?.trim()) {
throw new Error('Event type is required.')
}
let include: unknown
if (params.hookInclude) {
try {
include =
typeof params.hookInclude === 'string'
? JSON.parse(params.hookInclude)
: params.hookInclude
} catch {
throw new Error('Invalid JSON for include options')
}
}
return {
...baseParams,
hookUrl: params.hookUrl.trim(),
hookType: params.hookType.trim(),
include,
}
}
case 'grain_list_hooks_v2':
return {
...baseParams,
hookType: params.hookTypeFilter || undefined,
state: params.hookState || undefined,
}
case 'grain_delete_hook_v2':
if (!params.hookId?.trim()) {
throw new Error('Webhook ID is required.')
}
return {
...baseParams,
hookId: params.hookId.trim(),
}
default:
return GrainBlock.tools!.config!.params!(params)
}
},
},
},
inputs: {
...Object.fromEntries(Object.entries(GrainBlock.inputs).filter(([key]) => key !== 'viewId')),
apiKey: { type: 'string', description: 'Grain API key (Personal or Workspace Access Token)' },
hookType: { type: 'string', description: 'Grain event type for the webhook' },
hookInclude: {
type: 'json',
description: 'Optional include object controlling webhook payload richness',
},
hookTypeFilter: { type: 'string', description: 'Filter listed webhooks by event type' },
hookState: { type: 'string', description: 'Filter listed webhooks by enabled/disabled state' },
},
outputs: {
// Recording outputs (list + get; get returns the fields at top level)
recordings: { type: 'json', description: 'Array of recording objects' },
cursor: { type: 'string', description: 'Cursor for the next page (null when done)' },
id: { type: 'string', description: 'Recording or webhook UUID' },
title: { type: 'string', description: 'Recording title' },
start_datetime: { type: 'string', description: 'Recording start timestamp (ISO8601)' },
end_datetime: { type: 'string', description: 'Recording end timestamp (ISO8601)' },
duration_ms: { type: 'number', description: 'Duration in milliseconds' },
media_type: { type: 'string', description: 'Media type (audio/transcript/video)' },
source: { type: 'string', description: 'Recording source (zoom/meet/teams/etc)' },
url: { type: 'string', description: 'URL to view in Grain' },
thumbnail_url: { type: 'string', description: 'Thumbnail image URL' },
tags: { type: 'json', description: 'Array of tag strings' },
teams: { type: 'json', description: 'Teams ([{id, name}])' },
meeting_type: { type: 'json', description: 'Meeting type info (id, name, scope)' },
highlights: { type: 'json', description: 'Highlights/clips (if included)' },
participants: { type: 'json', description: 'Participants (if included)' },
ai_summary: { type: 'json', description: 'AI summary (if included)' },
ai_action_items: { type: 'json', description: 'AI action items (if included)' },
calendar_event: { type: 'json', description: 'Calendar event data (if included)' },
hubspot: { type: 'json', description: 'HubSpot associations (if included)' },
// Transcript outputs
transcript: { type: 'json', description: 'Array of transcript sections' },
// List outputs
meeting_types: { type: 'json', description: 'Array of meeting type objects' },
hooks: { type: 'json', description: 'Array of webhook objects' },
// Webhook outputs (create returns the hook fields at top level)
enabled: { type: 'boolean', description: 'Whether the created webhook is active' },
hook_url: { type: 'string', description: 'Webhook endpoint URL' },
hook_type: { type: 'string', description: 'Event type the webhook subscribes to' },
include: { type: 'json', description: 'Include object the webhook was created with' },
inserted_at: { type: 'string', description: 'Webhook creation timestamp (ISO8601)' },
success: { type: 'boolean', description: 'Operation success status' },
// Trigger outputs (v2 event payload envelope)
type: { type: 'string', description: 'Webhook event type (e.g., recording_added)' },
user_id: { type: 'string', description: 'User UUID who triggered the event' },
data: { type: 'json', description: 'Event data (recording, highlight, or story object)' },
},
triggers: {
enabled: true,
available: [...GRAIN_V2_TRIGGER_IDS],
},
}
export const GrainBlockMeta = {
tags: ['meeting', 'note-taking'],
url: 'https://grain.com',
@@ -548,7 +827,7 @@ export const GrainBlockMeta = {
description:
'Scan Grain sales-call transcripts for buying signals, objections, and competitor mentions.',
content:
'# Extract Deal Signals\n\nMine sales transcripts for signals that move a deal forward.\n\n## Steps\n1. List recordings for the target time window, or filter by a view that holds sales calls.\n2. Get the transcript for each recording.\n3. Classify mentions into buying signals, objections/risks, competitor mentions, and next steps, capturing the verbatim quote and context.\n4. Apply a framework (e.g. MEDDIC or SPICED) if one is specified to tag each insight.\n\n## Output\nReturn a structured list of signals grouped by category, each with the quote, the call it came from, and a suggested follow-up. Useful for CRM notes or a deal review.',
'# Extract Deal Signals\n\nMine sales transcripts for signals that move a deal forward.\n\n## Steps\n1. List recordings for the target time window, filtering by meeting type or team to isolate sales calls (use List Meeting Types / List Teams to find the IDs).\n2. Get the transcript for each recording.\n3. Classify mentions into buying signals, objections/risks, competitor mentions, and next steps, capturing the verbatim quote and context.\n4. Apply a framework (e.g. MEDDIC or SPICED) if one is specified to tag each insight.\n\n## Output\nReturn a structured list of signals grouped by category, each with the quote, the call it came from, and a suggested follow-up. Useful for CRM notes or a deal review.',
},
{
name: 'pull-transcript',
@@ -556,5 +835,21 @@ export const GrainBlockMeta = {
content:
'# Pull Transcript\n\nFetch a single recording and its transcript for downstream use.\n\n## Steps\n1. If only a title or date is known, list recordings and match to find the recording ID.\n2. Get the recording details for metadata (title, participants, duration, date).\n3. Get the transcript for the recording.\n4. Clean the transcript into readable speaker-labeled turns.\n\n## Output\nReturn the recording metadata plus the formatted transcript. This is the building block for summaries, follow-up emails, or knowledge base ingestion.',
},
{
name: 'audit-grain-webhooks',
description:
'List, create, and prune Grain webhook subscriptions so external systems only receive the events they need.',
content:
'# Audit Grain Webhooks\n\nKeep webhook subscriptions tidy and pointed at live endpoints.\n\n## Steps\n1. List webhooks, optionally filtered by event type or enabled/disabled state.\n2. Compare against the endpoints and event types that should exist; flag disabled hooks and hooks pointing at dead URLs.\n3. Delete stale or duplicate webhooks by ID.\n4. Create any missing webhooks with the right event type (note: the endpoint must respond 2xx to a reachability test on creation).\n\n## Output\nReturn a reconciliation summary: hooks kept, hooks deleted, hooks created, each with event type and URL.',
},
{
name: 'segment-calls-by-team',
description:
'Break down Grain call volume and content by team or meeting type for reporting.',
content:
'# Segment Calls By Team\n\nProduce a per-team or per-meeting-type view of call activity.\n\n## Steps\n1. List teams and meeting types to get their IDs.\n2. For each segment, list recordings filtered by that team or meeting type over the reporting window, paginating with the cursor.\n3. Aggregate counts, total duration, and notable calls per segment.\n\n## Output\nReturn a table-style summary per segment: call count, total hours, and links to representative recordings. Useful for weekly ops or enablement reporting.',
},
],
} as const satisfies BlockMeta
export const GrainV2BlockMeta = GrainBlockMeta
+3 -1
View File
@@ -127,7 +127,7 @@ import { GoogleTasksBlock, GoogleTasksBlockMeta } from '@/blocks/blocks/google_t
import { GoogleTranslateBlock, GoogleTranslateBlockMeta } from '@/blocks/blocks/google_translate'
import { GoogleVaultBlock, GoogleVaultBlockMeta } from '@/blocks/blocks/google_vault'
import { GrafanaBlock, GrafanaBlockMeta } from '@/blocks/blocks/grafana'
import { GrainBlock, GrainBlockMeta } from '@/blocks/blocks/grain'
import { GrainBlock, GrainBlockMeta, GrainV2Block, GrainV2BlockMeta } from '@/blocks/blocks/grain'
import { GranolaBlock, GranolaBlockMeta } from '@/blocks/blocks/granola'
import { GreenhouseBlock, GreenhouseBlockMeta } from '@/blocks/blocks/greenhouse'
import { GreptileBlock, GreptileBlockMeta } from '@/blocks/blocks/greptile'
@@ -460,6 +460,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
google_vault: GoogleVaultBlock,
grafana: GrafanaBlock,
grain: GrainBlock,
grain_v2: GrainV2Block,
granola: GranolaBlock,
greenhouse: GreenhouseBlock,
greptile: GreptileBlock,
@@ -763,6 +764,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
google_vault: GoogleVaultBlockMeta,
grafana: GrafanaBlockMeta,
grain: GrainBlockMeta,
grain_v2: GrainV2BlockMeta,
granola: GranolaBlockMeta,
greenhouse: GreenhouseBlockMeta,
greptile: GreptileBlockMeta,
@@ -313,11 +313,17 @@ export function Forks() {
const runRollback = async () => {
if (!undoableRun) return
try {
await rollback.mutateAsync({
const result = await rollback.mutateAsync({
workspaceId,
body: { otherWorkspaceId: undoableRun.otherWorkspaceId },
})
toast.success(`Undid sync from "${undoableRun.otherName}"`)
if (result.pendingActivations.length > 0) {
toast.warning(`Undid sync from "${undoableRun.otherName}"`, {
description: `${result.pendingActivations.length} restored deployment(s) are still activating. Undo stays available until they finish, in case a retry is needed.`,
})
} else {
toast.success(`Undid sync from "${undoableRun.otherName}"`)
}
setConfirmRollbackOpen(false)
} catch (err) {
toast.error(getErrorMessage(err, 'Undo failed'))
@@ -1,7 +1,12 @@
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { sha256Hex } from '@sim/security/hash'
import { and, eq } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { enqueueWorkflowDeploymentSideEffects } from '@/lib/workflows/deployment-outbox'
import {
DEPLOYMENT_READINESS_COMPONENTS,
enqueueWorkflowDeploymentPreparation,
} from '@/lib/workflows/deployment-outbox'
import { prepareWorkflowVersionActivation } from '@/lib/workflows/persistence/deployment-operations'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -16,24 +21,25 @@ interface ReactivateDeployedVersionParams {
export interface ReactivateDeployedVersionResult {
deploymentVersionId: string
/**
* Outbox event id enqueued inside the transaction. Process it AFTER the tx commits
* (or rely on the outbox cron/reaper if the process dies first).
* Deployment operation admitted (or reused) for this reactivation. The
* caller checks it post-commit to learn whether cutover completed.
*/
outboxEventId: string
operationId: string
/**
* Newly enqueued event. Reused idempotent operations already own a durable event.
*/
outboxEventId?: string
}
/**
* Reactivate a prior deployment version AND restore the workflow's draft to it using
* ONLY DB writes against the provided transaction, enqueuing the deployment
* side-effect (webhook / schedule / MCP re-subscription) to the outbox for processing
* AFTER the tx commits. This composes the DB halves of {@link activateWorkflowVersion}
* and `performRevertToVersion` so a fork rollback can run atomically under its fork
* advisory lock - the heavy side-effects never run inside the locked tx.
* Prepare a prior deployment version for v2 activation and restore the workflow's
* draft to it using only DB writes against the provided transaction.
*
* Deliberately does NOT call `assertWorkflowMutable`: a rollback is an admin force-undo
* and must not be blocked by a workflow/folder lock (that check is also not tx-safe).
* Idempotent: deactivate-all + activate-target + overwrite-draft yield the same state
* on retry.
* Idempotent: preparing the activation and overwriting the draft yield the same
* prepared operation and draft on retry; the cutover itself happens asynchronously
* through the deployment outbox.
*
* Returns null when the target version row no longer exists, so the caller can mark the
* workflow skipped rather than failing the whole rollback.
@@ -80,25 +86,6 @@ export async function reactivateDeployedVersionInTx(
)
}
// Activate the target version (deactivate every other), mark the workflow deployed.
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
await tx
.update(workflowDeploymentVersion)
.set({ isActive: true })
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
await tx
.update(workflow)
.set({ isDeployed: true, deployedAt: now })
.where(eq(workflow.id, workflowId))
// Restore the draft to the deployed version's state.
const hasVariables = Object.hasOwn(deployedState, 'variables')
const restoredState: WorkflowState = {
@@ -126,13 +113,34 @@ export async function reactivateDeployedVersionInTx(
})
.where(eq(workflow.id, workflowId))
const outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
let outboxEventId: string | undefined
const prepared = await prepareWorkflowVersionActivation({
workflowId,
deploymentVersionId: versionRow.id,
userId,
requestId,
forceRecreateSubscriptions: true,
actorId: userId,
requestHash: sha256Hex(JSON.stringify({ action: 'activate', workflowId, version, userId })),
idempotencyKey: `${requestId}:${workflowId}:reactivate:${version}`,
readinessComponents: DEPLOYMENT_READINESS_COMPONENTS,
tx,
onPrepareTransaction: async (innerTx, operation) => {
if (!operation.deploymentVersionId || operation.version === null) {
throw new Error('Prepared rollback activation is missing its target version')
}
outboxEventId = await enqueueWorkflowDeploymentPreparation(innerTx, {
protocolVersion: operation.protocolVersion,
operationId: operation.id,
generation: operation.generation,
workflowId: operation.workflowId,
deploymentVersionId: operation.deploymentVersionId,
version: operation.version,
userId,
requestId,
checkpoints: {},
})
},
})
return { deploymentVersionId: versionRow.id, outboxEventId }
if (!prepared.success) {
throw new Error(prepared.error)
}
return { deploymentVersionId: versionRow.id, operationId: prepared.operation.id, outboxEventId }
}
@@ -15,6 +15,7 @@ const {
mockEnqueueUndeploy,
mockProcessOutbox,
mockNotify,
mockGetDeploymentStatus,
} = vi.hoisted(() => ({
mockResolveForkEdge: vi.fn(),
mockAcquireTargetLock: vi.fn(),
@@ -27,6 +28,7 @@ const {
mockEnqueueUndeploy: vi.fn(),
mockProcessOutbox: vi.fn(),
mockNotify: vi.fn(),
mockGetDeploymentStatus: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
@@ -49,6 +51,10 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
undeployWorkflow: mockUndeploy,
}))
vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({
getWorkflowDeploymentStatus: mockGetDeploymentStatus,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
deleteWorkflowIdentityByIds: mockDeleteIdentity,
}))
@@ -101,7 +107,15 @@ describe('rollbackFork', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveForkEdge.mockResolvedValue(EDGE)
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv', outboxEventId: 'evt' })
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({
deploymentVersionId: `dv-${workflowId}`,
operationId: `op-${workflowId}`,
outboxEventId: `evt-${workflowId}`,
}))
mockGetDeploymentStatus.mockImplementation(async (workflowId: string) => ({
activeDeployment: null,
latestOperation: { id: `op-${workflowId}`, status: 'active' },
}))
mockUndeploy.mockResolvedValue({ success: true })
mockProcessOutbox.mockResolvedValue('completed')
setTx([])
@@ -119,10 +133,6 @@ describe('rollbackFork', () => {
},
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({
deploymentVersionId: `dv-${workflowId}`,
outboxEventId: `evt-${workflowId}`,
}))
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
@@ -136,6 +146,7 @@ describe('rollbackFork', () => {
unarchived: 0,
skipped: 0,
skippedIds: [],
pendingActivations: [],
})
// Deterministic (sorted) order: wf-a before wf-b.
expect(mockReactivate.mock.calls.map((c) => c[0].workflowId)).toEqual(['wf-a', 'wf-b'])
@@ -151,7 +162,6 @@ describe('rollbackFork', () => {
snapshot: { updated: [], created: [], archived: [{ workflowId: 'wf-x', priorVersion: 2 }] },
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv-x', outboxEventId: 'evt-x' })
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
@@ -166,7 +176,7 @@ describe('rollbackFork', () => {
expect(mockReactivate).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: 'wf-x', version: 2 })
)
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-x')
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-x')
expect(mockNotify).toHaveBeenCalledWith('wf-x')
})
@@ -205,7 +215,9 @@ describe('rollbackFork', () => {
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) =>
workflowId === 'wf-b' ? null : { deploymentVersionId: 'dv', outboxEventId: 'evt-wf-a' }
workflowId === 'wf-b'
? null
: { deploymentVersionId: 'dv', operationId: 'op-wf-a', outboxEventId: 'evt-wf-a' }
)
const result = await rollbackFork({
@@ -276,4 +288,83 @@ describe('rollbackFork', () => {
expect(result.skippedIds).toEqual(['wf-c'])
expect(result.archived).toBe(0)
})
it('preserves the undo point and reports pending activations while cutover settles', async () => {
const run = makeRun({
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
})
mockGetLatestRun.mockResolvedValue(run)
mockGetDeploymentStatus.mockResolvedValue({
activeDeployment: null,
latestOperation: { id: 'op-wf-a', status: 'preparing' },
})
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.pendingActivations).toEqual(['wf-a'])
expect(result.restored).toBe(1)
expect(mockDeleteAllRuns).not.toHaveBeenCalled()
})
it('keeps the undo point when no operation row exists to verify cutover', async () => {
const run = makeRun({
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
})
mockGetLatestRun.mockResolvedValue(run)
mockGetDeploymentStatus.mockResolvedValue({ activeDeployment: null, latestOperation: null })
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.pendingActivations).toEqual(['wf-a'])
expect(mockDeleteAllRuns).not.toHaveBeenCalled()
})
it('treats a superseding operation as settled and consumes the undo point', async () => {
const run = makeRun({
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
})
mockGetLatestRun.mockResolvedValue(run)
mockGetDeploymentStatus.mockResolvedValue({
activeDeployment: null,
latestOperation: { id: 'op-other', status: 'preparing' },
})
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.pendingActivations).toEqual([])
expect(mockDeleteAllRuns).toHaveBeenCalledTimes(1)
})
it('does not delete the undo point when a newer sync landed after the rollback committed', async () => {
const run = makeRun({
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
})
// Unlocked read + in-tx recheck see our run; the post-commit deletion recheck
// sees a newer sync's run, whose fresh undo point must survive.
mockGetLatestRun
.mockResolvedValueOnce(run)
.mockResolvedValueOnce(run)
.mockResolvedValueOnce(makeRun({ id: 'run-2' }))
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.pendingActivations).toEqual([])
expect(mockDeleteAllRuns).not.toHaveBeenCalled()
})
})
@@ -2,10 +2,12 @@ import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, inArray, isNull } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import {
enqueueWorkflowUndeploySideEffects,
processWorkflowDeploymentOutboxEvent,
} from '@/lib/workflows/deployment-outbox'
import { getWorkflowDeploymentStatus } from '@/lib/workflows/persistence/deployment-operations'
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import {
@@ -32,6 +34,13 @@ export interface RollbackForkParams {
}
export interface RollbackForkResult {
/**
* Workflows whose restored version has not finished (or has failed) its
* activation cutover. Their drafts are restored but live traffic stays on
* the pre-rollback version until the pending attempt completes; the undo
* point is preserved so the rollback can be re-run.
*/
pendingActivations: string[]
restored: number
archived: number
unarchived: number
@@ -59,10 +68,19 @@ type RollbackOp =
* retried by the outbox cron/reaper if this process dies first), so the locked
* transaction never holds across a network call. No draft blobs are stored - the
* deployed version is the source of truth.
*
* Activation cutovers settle asynchronously, so the undo point is deleted only
* after every restored version is verified live; while any activation is still
* pending (or failed), the undo point survives and re-running the rollback
* re-drives the remaining activations.
*/
export async function rollbackFork(params: RollbackForkParams): Promise<RollbackForkResult> {
const { targetWorkspaceId, otherWorkspaceId, userId } = params
const requestId = params.requestId ?? 'unknown'
/**
* The request id seeds reactivation idempotency keys; a generated fallback
* keeps distinct rollback invocations from colliding on a shared literal.
*/
const requestId = params.requestId ?? generateRequestId()
const edge = await resolveForkEdge(targetWorkspaceId, otherWorkspaceId)
if (!edge) {
@@ -111,6 +129,7 @@ export async function rollbackFork(params: RollbackForkParams): Promise<Rollback
const skipped = new Set<string>()
const outboxEventIds: string[] = []
const reactivations: Array<{ workflowId: string; operationId: string }> = []
await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
@@ -171,7 +190,8 @@ export async function rollbackFork(params: RollbackForkParams): Promise<Rollback
skipped.add(op.workflowId)
continue
}
outboxEventIds.push(result.outboxEventId)
reactivations.push({ workflowId: op.workflowId, operationId: result.operationId })
if (result.outboxEventId) outboxEventIds.push(result.outboxEventId)
continue
}
@@ -225,10 +245,6 @@ export async function rollbackFork(params: RollbackForkParams): Promise<Rollback
created
)
}
// Single-level undo: drop every undo point for this target so no older sibling
// sync becomes undoable once this one is undone.
await deleteAllPromoteRunsForTarget(tx, targetWorkspaceId)
})
// After commit: process the enqueued side-effects (webhooks / schedules / MCP). These
@@ -245,6 +261,69 @@ export async function rollbackFork(params: RollbackForkParams): Promise<Rollback
}
}
/**
* Reactivation cutovers settle asynchronously: the inline processing above
* completes them in the common case (a previously-live config reuses its
* registrations without provider calls), but a provider failure leaves an
* attempt pending or failed with live traffic still on the pre-rollback
* version. The undo point is deleted — enforcing single-level undo — only
* once every restored version is actually live; otherwise it is preserved
* so re-running the rollback re-drives the remaining activations
* (reactivation is idempotent by design).
*/
const pendingActivations: string[] = []
for (const reactivation of reactivations) {
try {
const status = await getWorkflowDeploymentStatus(reactivation.workflowId)
const operation = status.latestOperation
let activated: boolean
if (!operation) {
/**
* No operation row at all — cutover cannot be verified (only a
* concurrent hard-delete of the workflow can cascade ours away), so
* keep the undo point. A re-run skips hard-deleted workflows and
* then consumes it.
*/
activated = false
} else if (operation.id === reactivation.operationId) {
activated = operation.status === 'active'
} else {
/**
* A different latest operation means something newer superseded our
* attempt (e.g. a manual promote); treat it as settled — the newer
* intent owns the workflow now.
*/
activated = true
}
if (!activated) pendingActivations.push(reactivation.workflowId)
} catch (error) {
logger.warn(`[${requestId}] Could not verify rollback activation status`, {
workflowId: reactivation.workflowId,
error,
})
pendingActivations.push(reactivation.workflowId)
}
}
if (pendingActivations.length === 0) {
// Single-level undo: drop every undo point for this target so no older sibling
// sync becomes undoable once this one is undone. Re-checked under the fork lock
// because a promote may have landed since our commit — its fresh undo point must
// survive, so deletion only proceeds while our run is still the newest.
await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
await acquireForkTargetLock(tx, targetWorkspaceId)
const current = await getLatestPromoteRunForTarget(tx, targetWorkspaceId)
if (current && current.id !== run.id) return
await deleteAllPromoteRunsForTarget(tx, targetWorkspaceId)
})
} else {
logger.warn(
`[${requestId}] Rollback restored drafts but ${pendingActivations.length} activation(s) are still settling; undo point preserved for retry`,
{ targetWorkspaceId, pendingActivations }
)
}
if (skipped.size > 0) {
logger.warn(
`[${requestId}] Rollback skipped ${skipped.size} workflow(s) no longer in the database`,
@@ -282,6 +361,7 @@ export async function rollbackFork(params: RollbackForkParams): Promise<Rollback
const unarchived = archived.length - skippedArchived
const result: RollbackForkResult = {
pendingActivations,
restored,
archived: archivedCount,
unarchived,
@@ -294,6 +374,7 @@ export async function rollbackFork(params: RollbackForkParams): Promise<Rollback
archived: result.archived,
unarchived: result.unarchived,
skipped: result.skipped,
pendingActivations: result.pendingActivations.length,
})
return result
-5
View File
@@ -247,11 +247,6 @@ export const EVALUATOR = {
JSON_INDENT: 2,
} as const
export const CONDITION = {
ELSE_LABEL: 'else',
ELSE_TITLE: 'else',
} as const
export const PAUSE_RESUME = {
OPERATION: {
HUMAN: 'human',
@@ -211,6 +211,41 @@ describe('ConditionBlockHandler', () => {
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
})
it('recognizes legacy-capitalized else branches without evaluating them', async () => {
const conditions = [{ id: 'else1', title: 'Else', value: '' }]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).not.toHaveBeenCalled()
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id)
})
it('finds whitespace and mixed-case else branches during fallback', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { result: false } })
const conditions = [
{ id: 'cond1', title: 'if', value: 'false' },
{ id: 'else1', title: ' \t eLsE \n', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{
source: mockBlock.id,
target: mockTargetBlock1.id,
sourceHandle: 'condition-cond1',
},
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledOnce()
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath).toBeNull()
})
it('should handle invalid conditions JSON format', async () => {
const inputs = { conditions: '{ "invalid json ' }
@@ -1,7 +1,8 @@
import { createLogger } from '@sim/logger'
import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import { isElseConditionTitle } from '@/lib/workflows/conditions'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import { BlockType, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
@@ -211,12 +212,9 @@ export class ConditionBlockHandler implements BlockHandler {
selectedCondition: { id: string; title: string; value: string } | null
}> {
for (const condition of conditions) {
if (condition.title === CONDITION.ELSE_TITLE) {
if (isElseConditionTitle(condition.title)) {
const connection = this.findConnectionForCondition(outgoingConnections, condition.id)
if (connection) {
return { selectedConnection: connection, selectedCondition: condition }
}
continue
return { selectedConnection: connection ?? null, selectedCondition: condition }
}
const conditionValueString = String(condition.value || '')
@@ -241,15 +239,6 @@ export class ConditionBlockHandler implements BlockHandler {
}
}
const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE)
if (elseCondition) {
const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id)
if (elseConnection) {
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
}
return { selectedConnection: null, selectedCondition: elseCondition }
}
return { selectedConnection: null, selectedCondition: null }
}
+29 -34
View File
@@ -32,6 +32,7 @@ const logger = createLogger('DeploymentQueries')
export type { ChatDetail, DeploymentVersionsResponse }
export const DEPLOYMENT_INFO_STALE_TIME = 30 * 1000
export const DEPLOYMENT_STATUS_REFETCH_INTERVAL = 5 * 1000
export const DEPLOYED_WORKFLOW_STATE_STALE_TIME = 30 * 1000
export const DEPLOYMENT_VERSIONS_STALE_TIME = 30 * 1000
export const CHAT_DEPLOYMENT_STATUS_STALE_TIME = 30 * 1000
@@ -102,6 +103,9 @@ async function fetchDeploymentInfo(
apiKey: data.apiKey ?? null,
needsRedeployment: data.needsRedeployment ?? false,
isPublicApi: data.isPublicApi ?? false,
warnings: data.warnings,
activeDeployment: data.activeDeployment ?? null,
latestDeploymentAttempt: data.latestDeploymentAttempt ?? null,
}
}
@@ -109,16 +113,21 @@ async function fetchDeploymentInfo(
* Hook to fetch deployment info for a workflow.
* Provides isDeployed status, deployedAt timestamp, apiKey info, and needsRedeployment flag.
*/
export function useDeploymentInfo(
workflowId: string | null,
options?: { enabled?: boolean; refetchOnMount?: boolean | 'always' }
) {
export function useDeploymentInfo(workflowId: string | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: deploymentKeys.info(workflowId),
queryFn: ({ signal }) => fetchDeploymentInfo(workflowId!, signal),
enabled: Boolean(workflowId) && (options?.enabled ?? true),
staleTime: DEPLOYMENT_INFO_STALE_TIME,
...(options?.refetchOnMount !== undefined && { refetchOnMount: options.refetchOnMount }),
refetchInterval: (query) => {
const status = query.state.data?.latestDeploymentAttempt?.status
return status === 'preparing' || status === 'activating'
? DEPLOYMENT_STATUS_REFETCH_INTERVAL
: false
},
refetchOnMount: 'always',
refetchOnWindowFocus: true,
refetchOnReconnect: true,
})
}
@@ -170,7 +179,9 @@ async function fetchDeploymentVersions(
/**
* Hook to fetch deployment versions for a workflow.
* Returns a list of all deployment versions with their metadata.
* Returns a list of all deployment versions with their metadata. Polls while
* any version has an in-flight deployment attempt so preparing/activating
* labels resolve to their terminal state without a manual refresh.
*/
export function useDeploymentVersions(workflowId: string | null, options?: { enabled?: boolean }) {
return useQuery({
@@ -178,6 +189,14 @@ export function useDeploymentVersions(workflowId: string | null, options?: { ena
queryFn: ({ signal }) => fetchDeploymentVersions(workflowId!, signal),
enabled: Boolean(workflowId) && (options?.enabled ?? true),
staleTime: DEPLOYMENT_VERSIONS_STALE_TIME,
refetchInterval: (query) => {
const hasInFlightVersion = query.state.data?.versions.some(
(version) =>
version.latestOperationStatus === 'preparing' ||
version.latestOperationStatus === 'activating'
)
return hasInFlightVersion ? DEPLOYMENT_STATUS_REFETCH_INTERVAL : false
},
})
}
@@ -304,6 +323,8 @@ export function useDeployWorkflow() {
deployedAt: data.deployedAt ?? undefined,
apiKey: data.apiKey ?? undefined,
warnings: data.warnings,
activeDeployment: data.activeDeployment,
latestDeploymentAttempt: data.latestDeploymentAttempt,
}
},
onSettled: (_data, error, variables) => {
@@ -539,40 +560,14 @@ export function useActivateDeploymentVersion() {
body: { isActive: true },
})
},
onMutate: async ({ workflowId, version }) => {
await queryClient.cancelQueries({ queryKey: deploymentKeys.versions(workflowId) })
const previousVersions = queryClient.getQueryData<DeploymentVersionsResponse>(
deploymentKeys.versions(workflowId)
)
if (previousVersions) {
queryClient.setQueryData<DeploymentVersionsResponse>(deploymentKeys.versions(workflowId), {
versions: previousVersions.versions.map((v) => ({
...v,
isActive: v.version === version,
})),
})
}
return { previousVersions }
},
onError: (_, variables, context) => {
logger.error('Failed to activate deployment version')
if (context?.previousVersions) {
queryClient.setQueryData(
deploymentKeys.versions(variables.workflowId),
context.previousVersions
)
}
},
onSettled: (_data, error, variables) => {
if (!error) {
logger.info('Deployment version activated', {
workflowId: variables.workflowId,
version: variables.version,
})
} else {
logger.error('Failed to activate deployment version', { error })
}
return invalidateDeploymentQueries(queryClient, variables.workflowId)
},
+51 -1
View File
@@ -1,6 +1,11 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows'
import {
DEPLOYMENT_COMPONENT_STATUSES,
DEPLOYMENT_OPERATION_ACTIONS,
DEPLOYMENT_OPERATION_STATUSES,
} from '@/lib/workflows/deployment-lifecycle'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const deployedWorkflowStateSchema = z.custom<WorkflowState>(
@@ -56,6 +61,46 @@ export const activateDeploymentVersionBodySchema = z.object({
export type ActivateDeploymentVersionBody = z.input<typeof activateDeploymentVersionBodySchema>
export const deploymentOperationStatusSchema = z.enum(DEPLOYMENT_OPERATION_STATUSES)
export const deploymentComponentReadinessSchema = z.enum([
...DEPLOYMENT_COMPONENT_STATUSES,
'not_applicable',
])
export const deploymentReadinessSchema = z.object({
webhooks: deploymentComponentReadinessSchema,
schedules: deploymentComponentReadinessSchema,
mcp: deploymentComponentReadinessSchema,
})
export const deploymentOperationSummarySchema = z.object({
id: z.string(),
deploymentVersionId: z.string(),
version: z.number().int().positive(),
action: z.enum(DEPLOYMENT_OPERATION_ACTIONS),
status: deploymentOperationStatusSchema,
readiness: deploymentReadinessSchema,
requestedAt: z.string(),
activatedAt: z.string().nullable().optional(),
error: z
.object({
code: z.string(),
message: z.string(),
retryable: z.boolean(),
})
.nullable()
.optional(),
})
export type DeploymentOperationSummary = z.output<typeof deploymentOperationSummarySchema>
export const activeDeploymentSummarySchema = z.object({
deploymentVersionId: z.string(),
version: z.number().int().positive(),
deployedAt: z.string(),
})
export const deploymentVersionPatchBodySchema = deploymentVersionMetadataFieldsSchema
.extend({
isActive: z.literal(true).optional(),
@@ -74,6 +119,8 @@ export const deploymentInfoResponseSchema = z.object({
needsRedeployment: z.boolean().optional(),
isPublicApi: z.boolean().optional(),
warnings: z.array(z.string()).optional(),
activeDeployment: activeDeploymentSummarySchema.nullable().optional(),
latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(),
})
export type DeploymentInfoResponse = z.output<typeof deploymentInfoResponseSchema>
@@ -89,6 +136,7 @@ export const deploymentVersionSchema = z.object({
createdAt: z.string(),
createdBy: z.string().nullable().optional(),
deployedBy: z.string().nullable().optional(),
latestOperationStatus: deploymentOperationStatusSchema.nullable().optional(),
})
export type DeploymentVersion = z.output<typeof deploymentVersionSchema>
@@ -168,10 +216,12 @@ export type UpdateDeploymentVersionMetadataResponse = z.output<
export const activateDeploymentVersionResponseSchema = z.object({
success: z.literal(true),
deployedAt: z.string(),
deployedAt: z.string().nullable().optional(),
warnings: z.array(z.string()).optional(),
name: z.string().nullable().optional(),
description: z.string().nullable().optional(),
activeDeployment: activeDeploymentSummarySchema.nullable().optional(),
latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(),
})
export type ActivateDeploymentVersionResponse = z.output<
@@ -1,4 +1,8 @@
import { z } from 'zod'
import {
activeDeploymentSummarySchema,
deploymentOperationSummarySchema,
} from '@/lib/api/contracts/deployments'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import {
adminV1ExportFormatQuerySchema,
@@ -130,10 +134,12 @@ export const adminV1DeploymentVersionSchema = z.object({
})
export const adminV1DeployResultSchema = z.object({
isDeployed: z.literal(true),
version: z.number(),
deployedAt: z.string(),
isDeployed: z.boolean(),
version: z.number().nullable(),
deployedAt: z.string().nullable(),
warnings: z.array(z.string()).optional(),
activeDeployment: activeDeploymentSummarySchema.nullable().optional(),
latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(),
})
export const adminV1UndeployResultSchema = z.object({
@@ -171,8 +177,10 @@ const adminV1WorkflowVersionsResultSchema = z.object({
const adminV1ActivateWorkflowVersionResultSchema = z.object({
success: z.literal(true),
version: z.number(),
deployedAt: z.string(),
deployedAt: z.string().nullable(),
warnings: z.array(z.string()).optional(),
activeDeployment: activeDeploymentSummarySchema.nullable().optional(),
latestDeploymentAttempt: deploymentOperationSummarySchema.nullable().optional(),
})
export const adminV1ListWorkflowsContract = defineRouteContract({
+19 -3
View File
@@ -1,5 +1,9 @@
import { z } from 'zod'
import { deploymentVersionMetadataFieldsSchema } from '@/lib/api/contracts/deployments'
import {
activeDeploymentSummarySchema,
deploymentOperationSummarySchema,
deploymentVersionMetadataFieldsSchema,
} from '@/lib/api/contracts/deployments'
import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows'
@@ -93,13 +97,25 @@ const v1DeploymentStateSchema = z.object({
warnings: z.array(z.string()),
})
export const v1DeployWorkflowDataSchema = v1DeploymentStateSchema.extend({
/**
* Deploy/rollback admit asynchronously: HTTP success means the attempt was
* accepted, while `isDeployed` reflects whether a version is actually live.
* `latestDeploymentAttempt` carries the lifecycle status
* (preparing/activating/active/failed/superseded) so API consumers can poll
* to a terminal state instead of guessing from `isDeployed` alone.
*/
const v1DeploymentLifecycleSchema = v1DeploymentStateSchema.extend({
activeDeployment: activeDeploymentSummarySchema.nullable(),
latestDeploymentAttempt: deploymentOperationSummarySchema.nullable(),
})
export const v1DeployWorkflowDataSchema = v1DeploymentLifecycleSchema.extend({
version: z.number().optional(),
})
export type V1DeployWorkflowData = z.output<typeof v1DeployWorkflowDataSchema>
export const v1RollbackWorkflowDataSchema = v1DeploymentStateSchema.extend({
export const v1RollbackWorkflowDataSchema = v1DeploymentLifecycleSchema.extend({
version: z.number(),
})
+2
View File
@@ -345,6 +345,8 @@ export const executeWorkflowBodySchema = z.object({
includeFileBase64: z.boolean().optional().default(true),
base64MaxBytes: z.number().int().positive().optional(),
workflowStateOverride: workflowStateSchema.optional(),
/** Internal MCP bridge pin for calls admitted before a deployment cutover. */
deploymentVersionId: z.string().min(1).optional(),
executionId: z.unknown().optional(),
triggerBlockId: z.string().optional(),
startBlockId: z.string().optional(),
@@ -732,6 +732,11 @@ export const rollbackForkContract = defineRouteContract({
unarchived: z.number().int(),
/** Snapshot workflows that no longer exist and couldn't be reactivated. */
skipped: z.number().int(),
/**
* Workflows whose restored version has not finished its activation
* cutover; the undo point is preserved so the rollback can be re-run.
*/
pendingActivations: z.array(z.string()),
}),
},
})
@@ -185,7 +185,6 @@ export async function executeDeployApi(
const result = await performFullDeploy({
workflowId,
userId: context.userId,
workflowName: workflowRecord.name || undefined,
versionDescription,
versionName,
})
@@ -197,19 +196,24 @@ export async function executeDeployApi(
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
const isDeployed = Boolean(result.activeDeployment)
return {
success: true,
output: {
workflowId,
isDeployed: true,
isDeployed,
deployedAt: result.deployedAt,
version: result.version,
lifecycleStatus: result.latestDeploymentAttempt?.status ?? null,
readiness: result.latestDeploymentAttempt?.readiness ?? null,
error: result.latestDeploymentAttempt?.error ?? null,
warnings: result.warnings ?? [],
apiEndpoint,
baseUrl,
deploymentType: 'api',
deploymentStatus: {
api: {
isDeployed: true,
isDeployed,
endpoint: apiEndpoint,
deployedAt: result.deployedAt,
version: result.version,
@@ -802,19 +806,24 @@ export async function executeRedeploy(
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
const isDeployed = Boolean(result.activeDeployment)
return {
success: true,
output: {
workflowId,
isDeployed: true,
isDeployed,
deployedAt: result.deployedAt || null,
version: result.version,
lifecycleStatus: result.latestDeploymentAttempt?.status ?? null,
readiness: result.latestDeploymentAttempt?.readiness ?? null,
error: result.latestDeploymentAttempt?.error ?? null,
warnings: result.warnings ?? [],
apiEndpoint,
baseUrl,
deploymentType: 'api',
deploymentStatus: {
api: {
isDeployed: true,
isDeployed,
endpoint: apiEndpoint,
deployedAt: result.deployedAt || null,
version: result.version,
@@ -13,6 +13,8 @@ const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() =
const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion
const performActivateVersionMock = workflowsOrchestrationMockFns.mockPerformActivateVersion
const getWorkflowDeploymentSummaryMock =
workflowsOrchestrationMockFns.mockGetWorkflowDeploymentSummary
const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock, listWorkflowVersionsMock } =
vi.hoisted(() => ({
@@ -182,6 +184,17 @@ describe('executePromoteToLive', () => {
performActivateVersionMock.mockResolvedValue({
success: true,
deployedAt: new Date('2026-05-30T00:00:00.000Z'),
latestDeploymentAttempt: {
id: 'op-1',
deploymentVersionId: 'dv-3',
version: 3,
action: 'activate',
status: 'active',
readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' },
requestedAt: '2026-05-30T00:00:00.000Z',
activatedAt: '2026-05-30T00:00:00.000Z',
error: null,
},
})
const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, {
@@ -194,13 +207,14 @@ describe('executePromoteToLive', () => {
workflowId: 'wf-1',
version: 3,
userId: 'user-1',
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
})
expect(result.success).toBe(true)
expect(result.output).toMatchObject({
workflowId: 'wf-1',
version: 3,
message: 'Promoted version 3 to live',
lifecycleStatus: 'active',
error: null,
})
})
@@ -322,13 +336,25 @@ describe('executeCheckDeploymentStatus', () => {
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
})
checkNeedsRedeploymentMock.mockResolvedValue(false)
getWorkflowDeploymentSummaryMock.mockResolvedValue({
activeDeployment: null,
latestDeploymentAttempt: null,
warnings: [],
})
})
it('uses the shared redeployment freshness helper for deployed APIs', async () => {
getWorkflowDeploymentSummaryMock.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 1,
deployedAt: '2026-05-28T00:00:00.000Z',
},
latestDeploymentAttempt: null,
warnings: [],
})
vi.mocked(db.select)
.mockReturnValueOnce(
selectChain([{ isDeployed: true, deployedAt: new Date('2026-05-28') }]) as never
)
.mockReturnValueOnce(selectChain([{ deployedAt: new Date('2026-05-28') }]) as never)
.mockReturnValueOnce(selectChain([]) as never)
.mockReturnValueOnce(selectChain([], true) as never)
checkNeedsRedeploymentMock.mockResolvedValueOnce(true)
@@ -351,7 +377,7 @@ describe('executeCheckDeploymentStatus', () => {
it('does not check redeployment freshness for undeployed APIs', async () => {
vi.mocked(db.select)
.mockReturnValueOnce(selectChain([{ isDeployed: false, deployedAt: null }]) as never)
.mockReturnValueOnce(selectChain([{ deployedAt: null }]) as never)
.mockReturnValueOnce(selectChain([]) as never)
.mockReturnValueOnce(selectChain([], true) as never)
@@ -9,7 +9,11 @@ import {
performUpdateWorkflowMcpServer,
} from '@/lib/mcp/orchestration'
import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison'
import { performActivateVersion, performRevertToVersion } from '@/lib/workflows/orchestration'
import {
getWorkflowDeploymentSummary,
performActivateVersion,
performRevertToVersion,
} from '@/lib/workflows/orchestration'
import {
listWorkflowVersions,
updateDeploymentVersionMetadata,
@@ -42,9 +46,9 @@ export async function executeCheckDeploymentStatus(
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
const [apiDeploy, chatDeploy] = await Promise.all([
const [apiDeploy, chatDeploy, deploymentSummary] = await Promise.all([
db
.select({ isDeployed: workflow.isDeployed, deployedAt: workflow.deployedAt })
.select({ deployedAt: workflow.deployedAt })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1),
@@ -63,9 +67,15 @@ export async function executeCheckDeploymentStatus(
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1),
getWorkflowDeploymentSummary(workflowId),
])
const isApiDeployed = apiDeploy[0]?.isDeployed || false
/**
* Deployed means an active version snapshot exists; the legacy
* `workflow.isDeployed` flag is not consulted so this can never
* contradict the attached `activeDeployment` summary.
*/
const isApiDeployed = deploymentSummary.activeDeployment !== null
const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false
const apiDetails = {
isDeployed: isApiDeployed,
@@ -73,6 +83,9 @@ export async function executeCheckDeploymentStatus(
endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null,
apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys',
needsRedeployment,
activeDeployment: deploymentSummary.activeDeployment,
latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt,
warnings: deploymentSummary.warnings ?? [],
}
const isChatDeployed = !!chatDeploy[0]
@@ -362,6 +375,7 @@ export async function executeGetDeploymentLog(
name: r.name ?? undefined,
description: r.description ?? undefined,
isActive: r.isActive,
latestOperationStatus: r.latestOperationStatus ?? undefined,
createdAt: r.createdAt.toISOString(),
createdBy: r.createdBy ?? undefined,
}))
@@ -539,20 +553,25 @@ export async function executePromoteToLive(
workflowId,
version,
userId: context.userId,
workflow: workflowRecord as Record<string, unknown>,
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to promote version' }
}
const isActive = result.latestDeploymentAttempt?.status === 'active'
return {
success: true,
output: {
workflowId,
version,
message: `Promoted version ${version} to live`,
message: isActive
? `Promoted version ${version} to live`
: `Started preparing version ${version} for promotion`,
deployedAt: result.deployedAt ? new Date(result.deployedAt).toISOString() : undefined,
lifecycleStatus: result.latestDeploymentAttempt?.status ?? null,
readiness: result.latestDeploymentAttempt?.readiness ?? null,
error: result.latestDeploymentAttempt?.error ?? null,
warnings: result.warnings,
},
}
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { createBlockFromParams } from './builders'
import { createBlockFromParams } from '@/lib/copilot/tools/server/workflow/edit-workflow/builders'
const agentBlockConfig = {
type: 'agent',
@@ -69,4 +69,15 @@ describe('createBlockFromParams', () => {
expect(parsed[0].id).toBe('condition-1-if')
expect(parsed[1].id).toBe('condition-1-else')
})
it('uses lowercase titles for default condition branches', () => {
const block = createBlockFromParams('condition-1', {
type: 'condition',
name: 'Condition 1',
triggerMode: false,
})
const conditions = JSON.parse(block.subBlocks.conditions.value)
expect(conditions.map(({ title }: { title: string }) => title)).toEqual(['if', 'else'])
})
})
@@ -161,8 +161,8 @@ export function createBlockFromParams(
id: 'conditions',
type: 'condition-input',
value: JSON.stringify([
{ id: generateId(), title: 'If', value: '' },
{ id: generateId(), title: 'Else', value: '' },
{ id: generateId(), title: 'if', value: '' },
{ id: generateId(), title: 'else', value: '' },
]),
}
} else if (params.type === 'router_v2' && !blockState.subBlocks.routes?.value) {
+24
View File
@@ -395,6 +395,30 @@ describe('processOutboxEvents — handler timeout', () => {
expect(timeoutUpdate?.set.attempts).toBe(1)
expect(timeoutUpdate?.set.lastError).toMatch(/timed out/)
})
it('aborts the handler signal when its execution window expires', async () => {
let handlerSignal: AbortSignal | undefined
const handler = vi.fn(
async (
_payload: unknown,
context: { maxAttempts: number; signal: AbortSignal }
): Promise<void> => {
handlerSignal = context.signal
expect(context.maxAttempts).toBe(10)
await new Promise<void>((resolve) => {
context.signal.addEventListener('abort', () => resolve(), { once: true })
})
}
)
state.claimedRows = [makePendingRow({ attempts: 0 })]
const promise = processOutboxEvents({ 'test.event': handler })
await vi.advanceTimersByTimeAsync(90 * 1000 + 1)
const result = await promise
expect(handlerSignal?.aborted).toBe(true)
expect(result.leaseLost).toBe(1)
})
})
describe('processOutboxEvents — reaper recovery', () => {
+26 -1
View File
@@ -3,11 +3,23 @@ import { outboxEvent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { and, asc, eq, inArray, lte, sql } from 'drizzle-orm'
const logger = createLogger('OutboxService')
const DEFAULT_MAX_ATTEMPTS = 10
const MAX_PERSISTED_ERROR_LENGTH = 500
/**
* Bounds a handler failure before persisting it to `last_error`. Driver
* errors ("Failed query: ...\nparams: ...") embed every bound parameter,
* which can include user credentials from handler payloads the parameter
* tail is dropped and the rest is capped.
*/
function toPersistedHandlerError(error: unknown): string {
return truncate(toError(error).message.split(/\nparams: /)[0], MAX_PERSISTED_ERROR_LENGTH)
}
const STUCK_PROCESSING_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes
const MAX_BACKOFF_MS = 60 * 60 * 1000 // 1 hour
const BASE_BACKOFF_MS = 1000 // 1 second, doubled per attempt
@@ -37,6 +49,13 @@ export interface OutboxEventContext {
eventType: string
/** How many times this event has been attempted (zero on first run). */
attempts: number
/** Maximum attempts before this event is dead-lettered. */
maxAttempts: number
/**
* Aborted when the handler exceeds its lease-bound execution window.
* External-operation handlers must stop before performing another side effect.
*/
signal: AbortSignal
/**
* Durably shallow-merge fields into this event's JSON payload while the
* current processing lease is still held. Long-running handlers can
@@ -397,7 +416,7 @@ async function runHandler(
const nextAttempts = event.attempts + 1
const isDead = nextAttempts >= event.maxAttempts
const errMsg = toError(error).message
const errMsg = toPersistedHandlerError(error)
if (isDead) {
const updated = await updateIfLeaseHeld(event, {
@@ -558,13 +577,18 @@ function runHandlerWithTimeout(
event: typeof outboxEvent.$inferSelect,
timeoutMs: number = DEFAULT_HANDLER_TIMEOUT_MS
): Promise<void> {
const controller = new AbortController()
const context: OutboxEventContext = {
eventId: event.id,
eventType: event.eventType,
attempts: event.attempts,
maxAttempts: event.maxAttempts,
signal: controller.signal,
checkpointPayload: async (patch) => {
controller.signal.throwIfAborted()
const updated = await mergePayloadIfLeaseHeld(event, patch)
if (!updated) {
controller.abort()
throw new Error(`Outbox lease lost while checkpointing event ${event.id}`)
}
event.payload = {
@@ -576,6 +600,7 @@ function runHandlerWithTimeout(
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
controller.abort()
reject(new OutboxHandlerTimeoutError(timeoutMs))
}, timeoutMs)
@@ -336,6 +336,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
grain: GrainIcon,
grain_v2: GrainIcon,
granola: GranolaIcon,
greenhouse: GreenhouseIcon,
greptile: GreptileIcon,
+49 -38
View File
@@ -8127,7 +8127,7 @@
"tags": ["monitoring", "data-analytics"]
},
{
"type": "grain",
"type": "grain_v2",
"slug": "grain",
"name": "Grain",
"description": "Access meeting recordings, transcripts, and AI summaries",
@@ -8148,10 +8148,6 @@
"name": "Get Transcript",
"description": "Get the full transcript of a recording"
},
{
"name": "List Views",
"description": "List available Grain views for webhook subscriptions"
},
{
"name": "List Teams",
"description": "List all teams in the workspace"
@@ -8162,61 +8158,76 @@
},
{
"name": "Create Webhook",
"description": "Create a webhook to receive recording events"
"description": "Create a webhook for a specific Grain event type (v2 API)"
},
{
"name": "List Webhooks",
"description": "List all webhooks for the account"
"description": "List webhooks for the account (v2 API)"
},
{
"name": "Delete Webhook",
"description": "Delete a webhook by ID"
"description": "Delete a webhook by ID (v2 API)"
}
],
"operationCount": 9,
"operationCount": 8,
"triggers": [
{
"id": "grain_item_added",
"name": "Grain Item Added",
"description": "Trigger when a new item is added to a Grain view (recording, highlight, or story)"
"id": "grain_recording_added_v2",
"name": "Grain Recording Added",
"description": "Trigger when a new recording is added in Grain"
},
{
"id": "grain_item_updated",
"name": "Grain Item Updated",
"description": "Trigger when an item is updated in a Grain view (recording, highlight, or story)"
},
{
"id": "grain_webhook",
"name": "Grain All Events",
"description": "Trigger on all actions (added, updated, removed) in a Grain view"
},
{
"id": "grain_recording_created",
"name": "Grain Recording Created",
"description": "Trigger workflow when a new recording is added in Grain"
},
{
"id": "grain_recording_updated",
"id": "grain_recording_updated_v2",
"name": "Grain Recording Updated",
"description": "Trigger workflow when a recording is updated in Grain"
"description": "Trigger when a recording is updated in Grain"
},
{
"id": "grain_highlight_created",
"name": "Grain Highlight Created",
"description": "Trigger workflow when a new highlight/clip is created in Grain"
"id": "grain_recording_deleted_v2",
"name": "Grain Recording Deleted",
"description": "Trigger when a recording is deleted in Grain"
},
{
"id": "grain_highlight_updated",
"id": "grain_highlight_added_v2",
"name": "Grain Highlight Added",
"description": "Trigger when a new highlight/clip is created in Grain"
},
{
"id": "grain_highlight_updated_v2",
"name": "Grain Highlight Updated",
"description": "Trigger workflow when a highlight/clip is updated in Grain"
"description": "Trigger when a highlight/clip is updated in Grain"
},
{
"id": "grain_story_created",
"name": "Grain Story Created",
"description": "Trigger workflow when a new story is created in Grain"
"id": "grain_highlight_deleted_v2",
"name": "Grain Highlight Deleted",
"description": "Trigger when a highlight/clip is deleted in Grain"
},
{
"id": "grain_story_added_v2",
"name": "Grain Story Added",
"description": "Trigger when a new story is created in Grain"
},
{
"id": "grain_story_updated_v2",
"name": "Grain Story Updated",
"description": "Trigger when a story is updated in Grain"
},
{
"id": "grain_story_deleted_v2",
"name": "Grain Story Deleted",
"description": "Trigger when a story is deleted in Grain"
},
{
"id": "grain_upload_status_v2",
"name": "Grain Upload Status",
"description": "Trigger on progress updates for recordings uploaded to Grain"
},
{
"id": "grain_all_events_v2",
"name": "Grain All Events",
"description": "Trigger on every Grain event (recordings, highlights, stories, uploads)"
}
],
"triggerCount": 8,
"triggerCount": 11,
"authType": "api-key",
"category": "tools",
"integrationType": "productivity",
@@ -0,0 +1,55 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAnd, mockEq, mockIsNull } = vi.hoisted(() => ({
mockAnd: vi.fn((...conditions: unknown[]) => ({ kind: 'and', conditions })),
mockEq: vi.fn((column: unknown, value: unknown) => ({ kind: 'eq', column, value })),
mockIsNull: vi.fn((column: unknown) => ({ kind: 'isNull', column })),
}))
vi.mock('drizzle-orm', () => ({
and: mockAnd,
eq: mockEq,
isNull: mockIsNull,
}))
import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate'
const columns = {
isActive: 'webhook.isActive',
archivedAt: 'webhook.archivedAt',
} as unknown as Parameters<typeof deliverableWebhookPredicate>[0]
describe('deliverableWebhookPredicate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses the active, non-archived legacy delivery predicate by default', () => {
const predicate = deliverableWebhookPredicate(columns)
expect(mockEq).toHaveBeenCalledWith('webhook.isActive', true)
expect(mockIsNull).toHaveBeenCalledWith('webhook.archivedAt')
expect(mockAnd).toHaveBeenCalledWith(
{ kind: 'eq', column: 'webhook.isActive', value: true },
{ kind: 'isNull', column: 'webhook.archivedAt' }
)
expect(predicate).toEqual({
kind: 'and',
conditions: [
{ kind: 'eq', column: 'webhook.isActive', value: true },
{ kind: 'isNull', column: 'webhook.archivedAt' },
],
})
})
it('preserves active-only behavior for legacy consumers that included archived rows', () => {
const predicate = deliverableWebhookPredicate(columns, 'active_only')
expect(predicate).toEqual({ kind: 'eq', column: 'webhook.isActive', value: true })
expect(mockIsNull).not.toHaveBeenCalled()
expect(mockAnd).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,21 @@
import type { webhook } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
type WebhookDeliveryColumns = Pick<typeof webhook, 'archivedAt' | 'isActive'>
export type LegacyWebhookDeliveryPolicy = 'active_unarchived' | 'active_only'
/**
* Builds the current webhook-row delivery predicate without assuming future lifecycle columns.
*
* Most delivery consumers exclude archived rows. The active-only policy exists solely to preserve
* legacy consumers that historically scanned archived active rows, such as subscription renewal.
*/
export function deliverableWebhookPredicate(
columns: WebhookDeliveryColumns,
policy: LegacyWebhookDeliveryPolicy = 'active_unarchived'
) {
const activePredicate = eq(columns.isActive, true)
if (policy === 'active_only') return activePredicate
return and(activePredicate, isNull(columns.archivedAt))
}
+307 -202
View File
@@ -1,17 +1,24 @@
import { db } from '@sim/db'
import { account, credential, webhook, workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { WebhookPathClaimConflictError } from '@/lib/webhooks/path-claims'
import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
hasWebhookConfigChanged,
projectDesiredWebhookProviderConfig,
} from '@/lib/webhooks/provider-subscriptions'
import { getProviderHandler } from '@/lib/webhooks/providers'
import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack'
import {
prepareStableWebhookRegistrations,
type StableDesiredWebhookRegistration,
} from '@/lib/webhooks/registration-service'
import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server'
import {
buildCanonicalIndex,
@@ -50,6 +57,17 @@ interface BuiltProviderConfig {
triggerPath: string
}
interface ResolvedWebhookConfig {
provider: string
providerConfig: Record<string, unknown>
triggerPath: string | null
routingKey: string | null
}
type ResolveWebhookConfigResult =
| { success: true; config: ResolvedWebhookConfig }
| { success: false; error: TriggerSaveError }
export async function validateTriggerWebhookConfigForDeploy(
blocks: Record<string, BlockState>
): Promise<TriggerSaveResult> {
@@ -339,6 +357,189 @@ async function resolveTriggerCredentialId(
return resolvedCredential?.id ?? null
}
async function resolveWebhookConfigForBlock(input: {
block: BlockState
workflow: Record<string, unknown>
userId: string
requestId: string
}): Promise<ResolveWebhookConfigResult | null> {
const triggerId = resolveTriggerId(input.block)
if (!triggerId || !isTriggerValid(triggerId)) return null
const triggerDef = getTrigger(triggerId)
const { providerConfig, missingFields, credentialReference, credentialServiceId, triggerPath } =
buildProviderConfig(input.block, triggerId, triggerDef)
if (missingFields.length > 0) {
return {
success: false,
error: {
message: `Missing required fields for ${triggerDef.name || triggerId}: ${missingFields.join(', ')}`,
status: 400,
},
}
}
if (providerConfig.requireAuth && !providerConfig.token) {
return {
success: false,
error: {
message:
'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.',
status: 400,
},
}
}
let credentialId: string | undefined
if (credentialReference && credentialServiceId) {
const workflowWorkspaceId =
typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined
if (!workflowWorkspaceId) {
return {
success: false,
error: {
message: `Cannot validate credentials for ${triggerDef.name || triggerId} without a workflow workspace`,
status: 400,
},
}
}
credentialId =
(await resolveTriggerCredentialId(
credentialReference,
workflowWorkspaceId,
credentialServiceId
)) ?? undefined
if (!credentialId) {
return {
success: false,
error: {
message: `The selected ${credentialServiceId} credential is not available in this workspace`,
status: 400,
},
}
}
providerConfig.credentialId = credentialId
}
let effectiveProvider = triggerDef.provider
let effectivePath: string | null = triggerPath
let routingKey: string | null = null
if (triggerId === 'slack_oauth') {
const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'custom'
if (appType === 'sim') {
const eventType =
typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null
if (eventType && !SIM_SUBSCRIBED_EVENTS.includes(eventType)) {
return {
success: false,
error: {
message:
'This event is not available on the Sim Slack app. Use a custom app or choose a supported event.',
status: 400,
},
}
}
if (!credentialId) {
return {
success: false,
error: { message: 'Select a Slack account for the trigger.', status: 400 },
}
}
let tokenOwnerUserId = input.userId
const resolvedAccount = await resolveOAuthAccountId(credentialId)
if (resolvedAccount?.accountId) {
const [owner] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolvedAccount.accountId))
.limit(1)
if (owner?.userId) tokenOwnerUserId = owner.userId
}
const botToken = await refreshAccessTokenIfNeeded(
credentialId,
tokenOwnerUserId,
input.requestId
)
if (!botToken) {
return {
success: false,
error: {
message: 'Could not access the connected Slack account. Reconnect it and try again.',
status: 400,
},
}
}
try {
const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken)
routingKey = teamId
if (botUserId) providerConfig.bot_user_id = botUserId
} catch (error: unknown) {
logger.error(
`[${input.requestId}] Slack team_id resolution failed for ${input.block.id}`,
error
)
return {
success: false,
error: {
message: 'Could not verify the connected Slack workspace. Reconnect it and try again.',
status: 400,
},
}
}
effectiveProvider = 'slack_app'
effectivePath = null
} else {
const botCredentialId =
typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined
if (!botCredentialId) {
return {
success: false,
error: { message: 'Select a Slack bot credential for the trigger.', status: 400 },
}
}
const botCredential = await getSlackBotCredential(botCredentialId)
if (!botCredential) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is missing or invalid. Reconnect it.',
status: 400,
},
}
}
const workflowWorkspace =
typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined
if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is not available in this workspace.',
status: 400,
},
}
}
effectiveProvider = 'slack'
effectivePath = null
routingKey = botCredentialId
providerConfig.credentialId = botCredentialId
if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId
}
}
return {
success: true,
config: {
provider: effectiveProvider,
providerConfig,
triggerPath: effectivePath,
routingKey,
},
}
}
async function configurePollingIfNeeded(
provider: string,
savedWebhook: Record<string, unknown>,
@@ -361,6 +562,95 @@ async function configurePollingIfNeeded(
return null
}
export interface PrepareStableTriggerWebhooksInput {
request: NextRequest
workflowId: string
workflow: Record<string, unknown>
userId: string
blocks: Record<string, BlockState>
requestId: string
deploymentVersionId: string
operationId: string
generation: number
signal?: AbortSignal
}
/**
* Prepares stable webhook registrations for the v2 deployment operation protocol.
*
* The legacy save path remains available below and retains its existing execution behavior.
*/
export async function prepareStableTriggerWebhooksForDeploy({
request,
workflowId,
workflow,
userId,
blocks,
requestId,
deploymentVersionId,
operationId,
generation,
signal,
}: PrepareStableTriggerWebhooksInput): Promise<TriggerSaveResult> {
const validationResult = await validateTriggerWebhookConfigForDeploy(blocks)
if (!validationResult.success) return validationResult
const desired: StableDesiredWebhookRegistration[] = []
const triggerBlocks = Object.values(blocks || {}).filter(
(block) => block && block.enabled !== false
)
for (const block of triggerBlocks) {
signal?.throwIfAborted()
const resolved = await resolveWebhookConfigForBlock({
block,
workflow,
userId,
requestId,
})
if (!resolved) continue
if (!resolved.success) return resolved
desired.push({
blockId: block.id,
provider: resolved.config.provider,
path: resolved.config.triggerPath,
routingKey: resolved.config.routingKey,
providerConfig: resolved.config.providerConfig,
desiredConfig: projectDesiredWebhookProviderConfig(resolved.config.providerConfig),
})
}
try {
await prepareStableWebhookRegistrations({
request,
fence: { workflowId, deploymentVersionId, operationId, generation },
workflow,
userId,
requestId,
desired,
signal,
})
return { success: true }
} catch (error) {
if (error instanceof WebhookPathClaimConflictError) {
return {
success: false,
error: {
message: `Webhook path "${error.path}" is already in use. Choose a different path.`,
status: 409,
},
}
}
return {
success: false,
error: {
message: getErrorMessage(error, 'Failed to prepare webhook registrations'),
status: 500,
},
}
}
}
/**
* Saves trigger webhook configurations as part of workflow deployment.
* Uses delete + create approach for changed/deleted webhooks.
@@ -411,227 +701,42 @@ export async function saveTriggerWebhooksForDeploy({
existingWebhookBlockIds: Array.from(webhooksByBlockId.keys()),
})
type WebhookConfig = {
provider: string
providerConfig: Record<string, unknown>
triggerPath: string | null
routingKey: string | null
triggerDef: ReturnType<typeof getTrigger>
}
const webhookConfigs = new Map<string, WebhookConfig>()
const webhookConfigs = new Map<string, ResolvedWebhookConfig>()
const webhooksToDelete: typeof existingWebhooks = []
const blocksNeedingWebhook: BlockState[] = []
for (const block of triggerBlocks) {
const triggerId = resolveTriggerId(block)
if (!triggerId || !isTriggerValid(triggerId)) continue
const resolved = await resolveWebhookConfigForBlock({
block,
workflow,
userId,
requestId,
})
if (!resolved) continue
if (!resolved.success) return resolved
const { provider, providerConfig, triggerPath, routingKey } = resolved.config
const triggerDef = getTrigger(triggerId)
const provider = triggerDef.provider
const { providerConfig, missingFields, credentialReference, credentialServiceId, triggerPath } =
buildProviderConfig(block, triggerId, triggerDef)
if (missingFields.length > 0) {
return {
success: false,
error: {
message: `Missing required fields for ${triggerDef.name || triggerId}: ${missingFields.join(', ')}`,
status: 400,
},
}
}
if (providerConfig.requireAuth && !providerConfig.token) {
return {
success: false,
error: {
message:
'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.',
status: 400,
},
}
}
let credentialId: string | undefined
if (credentialReference && credentialServiceId) {
const workflowWorkspaceId =
typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
if (!workflowWorkspaceId) {
return {
success: false,
error: {
message: `Cannot validate credentials for ${triggerDef.name || triggerId} without a workflow workspace`,
status: 400,
},
}
}
credentialId =
(await resolveTriggerCredentialId(
credentialReference,
workflowWorkspaceId,
credentialServiceId
)) ?? undefined
if (!credentialId) {
return {
success: false,
error: {
message: `The selected ${credentialServiceId} credential is not available in this workspace`,
status: 400,
},
}
}
providerConfig.credentialId = credentialId
}
/**
* The unified Slack trigger (`slack_oauth`) resolves to one of two backends
* by App Type: `sim` routes inbound events on the official Sim app by Slack
* `team_id` (routingKey, no path); `custom` routes by the reusable bot
* credential id. The team_id is derived here from the connected account via
* `auth.test` never from user input.
*/
let effectiveProvider = provider
let effectivePath: string | null = triggerPath
let routingKey: string | null = null
if (triggerId === 'slack_oauth') {
// Absent appType means custom: it's the only mode this ship exposes (the
// hidden selector seeds/persists 'custom'), and defaulting to sim would
// send credential-less configs down the OAuth/team-id branch.
const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'custom'
if (appType === 'sim') {
const eventType =
typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null
if (eventType && !SIM_SUBSCRIBED_EVENTS.includes(eventType)) {
return {
success: false,
error: {
message:
'This event is not available on the Sim Slack app. Use a custom app or choose a supported event.',
status: 400,
},
}
}
if (!credentialId) {
return {
success: false,
error: { message: 'Select a Slack account for the trigger.', status: 400 },
}
}
// Resolve the credential OWNER's token (not the deploying actor's) —
// in a shared workspace a teammate can deploy a trigger wired to
// someone else's Slack credential. Mirrors the runtime formatInput path.
let tokenOwnerUserId = userId
const resolvedAccount = await resolveOAuthAccountId(credentialId)
if (resolvedAccount?.accountId) {
const [owner] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolvedAccount.accountId))
.limit(1)
if (owner?.userId) tokenOwnerUserId = owner.userId
}
const botToken = await refreshAccessTokenIfNeeded(credentialId, tokenOwnerUserId, requestId)
if (!botToken) {
return {
success: false,
error: {
message: 'Could not access the connected Slack account. Reconnect it and try again.',
status: 400,
},
}
}
try {
const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken)
routingKey = teamId
if (botUserId) providerConfig.bot_user_id = botUserId
} catch (error: unknown) {
logger.error(`[${requestId}] Slack team_id resolution failed for ${block.id}`, error)
return {
success: false,
error: {
message:
'Could not verify the connected Slack workspace. Reconnect it and try again.',
status: 400,
},
}
}
effectiveProvider = 'slack_app'
effectivePath = null
} else {
// Custom: a reusable bring-your-own bot credential. Route by the
// credential id (one shared ingest URL per bot) instead of a per-workflow
// path, so multiple triggers on the same bot share one Request URL.
const botCredentialId =
typeof providerConfig.botCredential === 'string'
? providerConfig.botCredential
: undefined
if (!botCredentialId) {
return {
success: false,
error: { message: 'Select a Slack bot credential for the trigger.', status: 400 },
}
}
const botCredential = await getSlackBotCredential(botCredentialId)
if (!botCredential) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is missing or invalid. Reconnect it.',
status: 400,
},
}
}
// The credential must belong to the workflow's workspace: bot credential
// ids are semi-public (they're embedded in Slack Request URLs), so a
// pasted foreign id must never bind another tenant's bot to this
// workflow.
const workflowWorkspace =
typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is not available in this workspace.',
status: 400,
},
}
}
effectiveProvider = 'slack'
effectivePath = null
routingKey = botCredentialId
providerConfig.credentialId = botCredentialId
if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId
}
}
if (effectivePath) {
if (triggerPath) {
const pathConflict = await findConflictingWebhookPathOwner({
path: effectivePath,
path: triggerPath,
workflowId,
})
if (pathConflict) {
logger.warn(
`[${requestId}] Webhook path conflict for "${effectivePath}": already owned by workflow ${pathConflict}`
`[${requestId}] Webhook path conflict for "${triggerPath}": already owned by workflow ${pathConflict}`
)
return {
success: false,
error: {
message: `Webhook path "${effectivePath}" is already in use. Choose a different path.`,
message: `Webhook path "${triggerPath}" is already in use. Choose a different path.`,
status: 409,
},
}
}
}
webhookConfigs.set(block.id, {
provider: effectiveProvider,
providerConfig,
triggerPath: effectivePath,
routingKey,
triggerDef,
})
webhookConfigs.set(block.id, resolved.config)
const existingForBlock = webhooksByBlockId.get(block.id) ?? []
if (existingForBlock.length === 0) {
@@ -650,11 +755,11 @@ export async function saveTriggerWebhooksForDeploy({
const existingConfig = (existingWh.providerConfig as Record<string, unknown>) || {}
const needsRecreation =
forceRecreateSubscriptions ||
existingWh.provider !== effectiveProvider ||
existingWh.provider !== provider ||
// Routing transitions (path-based <-> routing-key, or a changed key)
// must recreate the row even when the provider config compares equal —
// otherwise a stale delivery surface stays active on the old route.
(existingWh.path ?? null) !== effectivePath ||
(existingWh.path ?? null) !== triggerPath ||
((existingWh.routingKey as string | null) ?? null) !== routingKey ||
hasWebhookConfigChanged(existingConfig, providerConfig)
+109
View File
@@ -0,0 +1,109 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
type Condition =
| { kind: 'and'; conditions: Condition[] }
| { kind: 'eq'; column: string; value: unknown }
| { kind: 'lte'; column: string; value: unknown }
vi.mock('drizzle-orm', () => ({
and: (...conditions: Condition[]) => ({ kind: 'and', conditions }),
eq: (column: string, value: unknown) => ({ kind: 'eq', column, value }),
lte: (column: string, value: unknown) => ({ kind: 'lte', column, value }),
}))
import type { DbOrTx } from '@sim/workflow-persistence/types'
import {
claimWebhookPath,
StaleWebhookPathClaimGenerationError,
WebhookPathClaimConflictError,
} from '@/lib/webhooks/path-claims'
interface ClaimRow {
path: string
workflowId: string
generation: number
}
function conditionValue(condition: Condition, column: string): unknown {
if (condition.kind === 'eq' && condition.column === column) return condition.value
if (condition.kind !== 'and') return undefined
for (const nested of condition.conditions) {
const value = conditionValue(nested, column)
if (value !== undefined) return value
}
return undefined
}
function createClaimTx(claims: Map<string, ClaimRow>): DbOrTx {
return {
insert: () => ({
values: (values: ClaimRow) => ({
onConflictDoUpdate: () => ({
returning: async () => {
const current = claims.get(values.path)
if (
current &&
(current.workflowId !== values.workflowId || current.generation > values.generation)
) {
return []
}
const claimed = { ...values }
claims.set(values.path, claimed)
return [claimed]
},
}),
}),
}),
select: () => ({
from: () => ({
where: (condition: Condition) => ({
limit: async () => {
const path = conditionValue(condition, 'path') as string
const current = claims.get(path)
return current ? [current] : []
},
}),
}),
}),
} as unknown as DbOrTx
}
describe('webhook path claims', () => {
it('atomically gives a normalized path to one workflow under concurrent claims', async () => {
const claims = new Map<string, ClaimRow>()
const results = await Promise.allSettled([
claimWebhookPath(createClaimTx(claims), {
path: ' /shared/path/ ',
workflowId: 'workflow-a',
generation: 1,
}),
claimWebhookPath(createClaimTx(claims), {
path: 'shared/path',
workflowId: 'workflow-b',
generation: 1,
}),
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
const rejection = results.find((result) => result.status === 'rejected')
expect(rejection).toBeDefined()
if (rejection?.status !== 'rejected') throw new Error('Expected one rejected claim')
expect(rejection.reason).toBeInstanceOf(WebhookPathClaimConflictError)
expect(claims.get('shared/path')?.workflowId).toMatch(/^workflow-[ab]$/)
})
it('allows the same workflow to advance but rejects a stale generation', async () => {
const claims = new Map<string, ClaimRow>()
const tx = createClaimTx(claims)
await claimWebhookPath(tx, { path: 'events', workflowId: 'workflow-a', generation: 3 })
await claimWebhookPath(tx, { path: '/events/', workflowId: 'workflow-a', generation: 4 })
await expect(
claimWebhookPath(tx, { path: 'events', workflowId: 'workflow-a', generation: 3 })
).rejects.toBeInstanceOf(StaleWebhookPathClaimGenerationError)
expect(claims.get('events')?.generation).toBe(4)
})
})
+117
View File
@@ -0,0 +1,117 @@
import { webhookPathClaim } from '@sim/db/schema'
import type { DbOrTx } from '@sim/workflow-persistence/types'
import { and, eq, lte } from 'drizzle-orm'
import { normalizeWebhookRegistrationPath } from '@/lib/webhooks/registration-identity'
export class WebhookPathClaimConflictError extends Error {
readonly code = 'webhook_path_conflict'
constructor(
readonly path: string,
readonly ownerWorkflowId: string
) {
super(`Webhook path "${path}" is already owned by workflow ${ownerWorkflowId}`)
this.name = 'WebhookPathClaimConflictError'
}
}
export class StaleWebhookPathClaimGenerationError extends Error {
readonly code = 'stale_webhook_path_claim_generation'
constructor(
readonly path: string,
readonly attemptedGeneration: number,
readonly currentGeneration: number
) {
super(
`Webhook path "${path}" is already fenced at generation ${currentGeneration}; generation ${attemptedGeneration} is stale`
)
this.name = 'StaleWebhookPathClaimGenerationError'
}
}
function normalizeClaimPath(path: string): string {
const normalizedPath = normalizeWebhookRegistrationPath(path)
if (!normalizedPath) {
throw new TypeError('Webhook path claim cannot be empty')
}
return normalizedPath
}
function assertClaimGeneration(generation: number): void {
if (!Number.isSafeInteger(generation) || generation < 0) {
throw new TypeError('Webhook path claim generation must be a non-negative safe integer')
}
}
/**
* Releases every path claim held by a workflow.
*
* Claims stay sticky through generation rotations, but once a workflow is
* explicitly undeployed or archived it no longer serves traffic, so other
* workflows must be able to adopt its paths. Runs inside the caller's
* undeploy/archive transaction.
*/
export async function releaseWebhookPathClaims(tx: DbOrTx, workflowId: string): Promise<void> {
await tx.delete(webhookPathClaim).where(eq(webhookPathClaim.workflowId, workflowId))
}
/**
* Atomically acquires or advances ownership of a normalized webhook path.
*
* Ownership never transfers between workflows. The conflict update is generation-CAS guarded,
* so the primary-key write is the ownership decision rather than a preceding availability check.
*/
export async function claimWebhookPath(
tx: DbOrTx,
input: { path: string; workflowId: string; generation: number }
): Promise<string> {
const path = normalizeClaimPath(input.path)
assertClaimGeneration(input.generation)
const now = new Date()
const [claimed] = await tx
.insert(webhookPathClaim)
.values({
path,
workflowId: input.workflowId,
generation: input.generation,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: webhookPathClaim.path,
set: {
generation: input.generation,
updatedAt: now,
},
setWhere: and(
eq(webhookPathClaim.workflowId, input.workflowId),
lte(webhookPathClaim.generation, input.generation)
),
})
.returning({
path: webhookPathClaim.path,
workflowId: webhookPathClaim.workflowId,
generation: webhookPathClaim.generation,
})
if (claimed) return claimed.path
const [current] = await tx
.select({
workflowId: webhookPathClaim.workflowId,
generation: webhookPathClaim.generation,
})
.from(webhookPathClaim)
.where(eq(webhookPathClaim.path, path))
.limit(1)
if (!current) {
throw new Error(`Webhook path "${path}" could not be claimed`)
}
if (current.workflowId !== input.workflowId) {
throw new WebhookPathClaimConflictError(path, current.workflowId)
}
throw new StaleWebhookPathClaimGenerationError(path, input.generation, current.generation)
}
+2 -2
View File
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { account, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import type { Logger } from '@sim/logger'
import { and, eq, isNull, ne, or, sql } from 'drizzle-orm'
import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate'
import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types'
import {
getOAuthToken,
@@ -80,8 +81,7 @@ export async function fetchActiveWebhooks(
.where(
and(
eq(webhook.provider, provider),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
deliverableWebhookPredicate(webhook),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
or(
+36 -2
View File
@@ -42,7 +42,10 @@ const {
mockReleaseExecutionSlot: vi.fn(),
mockProviderHandler: { current: {} as Record<string, unknown> },
mockShouldExecuteInline: vi.fn(),
mockWebhookLookupResult: { rows: [] as WebhookLookupRow[] },
mockWebhookLookupResult: {
rows: [] as WebhookLookupRow[],
claim: [] as Array<{ workflowId: string }>,
},
}))
const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
@@ -52,11 +55,15 @@ vi.mock('@sim/db', () => {
from: () => selectChain,
innerJoin: () => selectChain,
leftJoin: () => selectChain,
where: () => Promise.resolve(mockWebhookLookupResult.rows),
where: () => ({
then: (resolve: (rows: WebhookLookupRow[]) => void) => resolve(mockWebhookLookupResult.rows),
limit: () => Promise.resolve(mockWebhookLookupResult.claim),
}),
}
return {
db: { select: () => selectChain },
webhook: {},
webhookPathClaim: {},
workflow: {},
workflowDeploymentVersion: {},
}
@@ -222,6 +229,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebhookLookupResult.rows = []
mockWebhookLookupResult.claim = []
})
const makeRow = (workflowId: string, webhookId: string, createdAt: Date) => ({
@@ -253,6 +261,30 @@ describe('findAllWebhooksForPath cross-tenant collision', () => {
expect(results[0].webhook.workflowId).toBe('victim-workflow')
})
it('prefers the path-claim owner over an earlier-created interloper', async () => {
const interloper = makeRow('interloper-workflow', 'interloper-wh', new Date('2026-01-01'))
const claimHolder = makeRow('claim-workflow', 'claim-wh', new Date('2026-05-01'))
mockWebhookLookupResult.rows = [interloper, claimHolder]
mockWebhookLookupResult.claim = [{ workflowId: 'claim-workflow' }]
const results = await findAllWebhooksForPath({ requestId: 'req-6', path: 'shared-path' })
expect(results).toHaveLength(1)
expect(results[0].webhook.workflowId).toBe('claim-workflow')
})
it('falls back to earliest registration when the claim owner has no deliverable rows', async () => {
const victim = makeRow('victim-workflow', 'victim-wh', new Date('2026-01-01'))
const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01'))
mockWebhookLookupResult.rows = [attacker, victim]
mockWebhookLookupResult.claim = [{ workflowId: 'absent-workflow' }]
const results = await findAllWebhooksForPath({ requestId: 'req-7', path: 'shared-path' })
expect(results).toHaveLength(1)
expect(results[0].webhook.workflowId).toBe('victim-workflow')
})
it("preserves the owner's full multi-webhook match while dropping a foreign row", async () => {
const victimA = makeRow('victim-workflow', 'victim-wh-a', new Date('2026-01-01'))
const victimB = makeRow('victim-workflow', 'victim-wh-b', new Date('2026-01-03'))
@@ -436,6 +468,7 @@ describe('webhook processor execution identity', () => {
makeWebhookRecord({
path: 'incoming/gmail',
provider: 'gmail',
deploymentVersionId: 'deployment-admitted',
}),
makeWorkflowRecord({}),
{ event: 'message.received' },
@@ -453,6 +486,7 @@ describe('webhook processor execution identity', () => {
expect.objectContaining({
workflowId: 'workflow-1',
provider: 'gmail',
deploymentVersionId: 'deployment-admitted',
}),
expect.objectContaining({
metadata: expect.objectContaining({
+45 -20
View File
@@ -1,4 +1,4 @@
import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, webhook, webhookPathClaim, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -23,6 +23,7 @@ import {
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate'
import {
getPendingWebhookVerification,
matchesPendingWebhookVerificationProbe,
@@ -30,6 +31,7 @@ import {
} from '@/lib/webhooks/pending-verification'
import { getProviderHandler } from '@/lib/webhooks/providers'
import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
import { normalizeWebhookRegistrationPath } from '@/lib/webhooks/registration-identity'
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
import { SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants'
import { executeWebhookJob, type WebhookExecutionPayload } from '@/background/webhook-execution'
@@ -316,8 +318,7 @@ async function findWebhookAndWorkflow(
.where(
and(
eq(webhook.id, options.webhookId),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
deliverableWebhookPredicate(webhook),
isNull(workflow.archivedAt),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
@@ -353,8 +354,7 @@ async function findWebhookAndWorkflow(
.where(
and(
eq(webhook.path, options.path),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
deliverableWebhookPredicate(webhook),
isNull(workflow.archivedAt),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
@@ -380,8 +380,9 @@ async function findWebhookAndWorkflow(
*
* Legitimate multi-webhook matches are always within one workflow, but paths
* are user-controlled and only unique per deployment version, so two tenants can
* register the same path. On collision we keep only the workflow that registered
* the path first, so one tenant can never receive another's webhook deliveries.
* register the same path. On collision the `webhook_path_claim` owner wins;
* without a claim we keep the workflow that registered the path first, so one
* tenant can never receive another's webhook deliveries.
*/
export async function findAllWebhooksForPath(
options: WebhookProcessorOptions
@@ -407,8 +408,7 @@ export async function findAllWebhooksForPath(
.where(
and(
eq(webhook.path, options.path),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
deliverableWebhookPredicate(webhook),
isNull(workflow.archivedAt),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
@@ -425,19 +425,23 @@ export async function findAllWebhooksForPath(
const distinctWorkflowIds = new Set(results.map((result) => result.webhook.workflowId))
if (distinctWorkflowIds.size > 1) {
const owner = results.reduce((earliest, candidate) => {
const candidateTime = new Date(candidate.webhook.createdAt).getTime()
const earliestTime = new Date(earliest.webhook.createdAt).getTime()
if (candidateTime !== earliestTime) {
return candidateTime < earliestTime ? candidate : earliest
}
return candidate.webhook.id < earliest.webhook.id ? candidate : earliest
})
const claimOwnerWorkflowId = await findWebhookPathClaimOwner(options.path)
const owner =
(claimOwnerWorkflowId &&
results.find((result) => result.webhook.workflowId === claimOwnerWorkflowId)) ||
results.reduce((earliest, candidate) => {
const candidateTime = new Date(candidate.webhook.createdAt).getTime()
const earliestTime = new Date(earliest.webhook.createdAt).getTime()
if (candidateTime !== earliestTime) {
return candidateTime < earliestTime ? candidate : earliest
}
return candidate.webhook.id < earliest.webhook.id ? candidate : earliest
})
const ownerWorkflowId = owner.webhook.workflowId
const ownerResults = results.filter((result) => result.webhook.workflowId === ownerWorkflowId)
logger.error(
`[${options.requestId}] Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} and dropping ${results.length - ownerResults.length} foreign webhook(s).`
`[${options.requestId}] Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} (${claimOwnerWorkflowId === ownerWorkflowId ? 'path-claim owner' : 'earliest registration'}) and dropping ${results.length - ownerResults.length} foreign webhook(s).`
)
return ownerResults
@@ -450,6 +454,22 @@ export async function findAllWebhooksForPath(
return results
}
/**
* Resolves the sticky `webhook_path_claim` owner for a delivery path, so
* collision resolution can prefer the workflow that legitimately claimed the
* path over an interloper that registered a row first.
*/
async function findWebhookPathClaimOwner(path: string): Promise<string | null> {
const normalizedPath = normalizeWebhookRegistrationPath(path)
if (!normalizedPath) return null
const [claim] = await db
.select({ workflowId: webhookPathClaim.workflowId })
.from(webhookPathClaim)
.where(eq(webhookPathClaim.path, normalizedPath))
.limit(1)
return claim?.workflowId ?? null
}
/**
* Finds all active `slack_app` webhooks for a Slack `team_id` (the routing key).
*
@@ -486,8 +506,7 @@ export async function findWebhooksByRoutingKey(
and(
eq(webhook.routingKey, routingKey),
eq(webhook.provider, provider),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
deliverableWebhookPredicate(webhook),
isNull(workflow.archivedAt),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
@@ -752,6 +771,9 @@ async function queueWebhookExecutionWithResult(
headers,
path: options.path || foundWebhook.path || '',
blockId: foundWebhook.blockId ?? undefined,
...(foundWebhook.deploymentVersionId
? { deploymentVersionId: foundWebhook.deploymentVersionId }
: {}),
workspaceId,
...(credentialId ? { credentialId } : {}),
...(options.receivedAt !== undefined ? { webhookReceivedAt: options.receivedAt } : {}),
@@ -1063,6 +1085,9 @@ export async function processPolledWebhookEvent(
headers: { 'content-type': 'application/json' } as Record<string, string>,
path: foundWebhook.path ?? '',
blockId: foundWebhook.blockId ?? undefined,
...(foundWebhook.deploymentVersionId
? { deploymentVersionId: foundWebhook.deploymentVersionId }
: {}),
workspaceId,
...(credentialId ? { credentialId } : {}),
} satisfies WebhookExecutionPayload
@@ -17,7 +17,10 @@ vi.mock('@/lib/webhooks/providers', () => ({
getProviderHandler: mockGetProviderHandler,
}))
import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
describe('createExternalWebhookSubscription', () => {
beforeEach(() => {
@@ -119,3 +122,44 @@ describe('createExternalWebhookSubscription', () => {
expect(result.updatedProviderConfig.token).toBe('{{SLACK_TOKEN}}')
})
})
describe('cleanupExternalWebhook', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetEffectiveDecryptedEnv.mockResolvedValue({ CALENDLY_API_KEY: 'real-secret-key' })
})
it('resolves {{ENV_VAR}} references before deleting the provider subscription', async () => {
const deleteSubscription = vi.fn().mockResolvedValue(undefined)
mockGetProviderHandler.mockReturnValue({ deleteSubscription })
const webhook = {
id: 'webhook-1',
provider: 'calendly',
providerConfig: {
apiKey: '{{CALENDLY_API_KEY}}',
externalId: 'external-1',
},
}
const workflow = {
id: 'workflow-1',
userId: 'user-1',
workspaceId: 'workspace-1',
}
await cleanupExternalWebhook(webhook, workflow, 'request-1')
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-1', 'workspace-1')
expect(deleteSubscription).toHaveBeenCalledWith(
expect.objectContaining({
webhook: expect.objectContaining({
providerConfig: {
apiKey: 'real-secret-key',
externalId: 'external-1',
},
}),
})
)
expect(webhook.providerConfig.apiKey).toBe('{{CALENDLY_API_KEY}}')
})
})
@@ -1,7 +1,11 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { omit } from '@sim/utils/object'
import type { NextRequest } from 'next/server'
import { resolveWebhookProviderConfig } from '@/lib/webhooks/env-resolver'
import {
resolveWebhookProviderConfig,
resolveWebhookRecordProviderConfig,
} from '@/lib/webhooks/env-resolver'
import { getProviderHandler } from '@/lib/webhooks/providers'
const logger = createLogger('WebhookProviderSubscriptions')
@@ -29,10 +33,23 @@ const SYSTEM_MANAGED_FIELDS = new Set([
'secretToken',
'historyId',
'lastCheckedTimestamp',
'lastSeenGuids',
'setupCompleted',
'subscriptionExpiration',
'userId',
])
/**
* Returns the user-controlled projection used for stable registration identity.
*
* Provider-managed subscription metadata and mutable polling cursors are intentionally omitted.
*/
export function projectDesiredWebhookProviderConfig(
providerConfig: Readonly<Record<string, unknown>>
): Record<string, unknown> {
return omit(providerConfig, [...SYSTEM_MANAGED_FIELDS])
}
/** Returns true when user-controlled persisted webhook configuration changed. */
export function hasWebhookConfigChanged(
previousConfig: Record<string, unknown>,
@@ -105,7 +122,8 @@ export async function createExternalWebhookSubscription(
webhookData: Record<string, unknown>,
workflow: Record<string, unknown>,
userId: string,
requestId: string
requestId: string,
options: { signal?: AbortSignal } = {}
): Promise<ExternalSubscriptionResult> {
const provider = webhookData.provider as string
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
@@ -123,6 +141,13 @@ export async function createExternalWebhookSubscription(
workspaceId
)
/**
* Last abort check before the irreversible external call: a lease-expired
* outbox handler must not mint a provider resource it can no longer
* durably record.
*/
options.signal?.throwIfAborted()
const result = await handler.createSubscription({
webhook: { ...webhookData, providerConfig: resolvedProviderConfig },
workflow,
@@ -143,6 +168,9 @@ export async function createExternalWebhookSubscription(
/**
* Clean up external webhook subscriptions for a webhook.
* Resolves persisted `{{ENV_VAR}}` references with the workflow owner's
* effective environment before invoking the provider.
*
* By default, cleanup failure is logged but non-fatal for legacy best-effort callers.
* Deployment outbox cleanup passes `throwOnError` so provider failures stay retryable.
*/
@@ -160,7 +188,23 @@ export async function cleanupExternalWebhook(
}
try {
await handler.deleteSubscription({ webhook, workflow, requestId, strict: options.throwOnError })
if (typeof workflow.userId !== 'string') {
throw new Error('Cannot resolve webhook credentials without a workflow owner')
}
const workspaceId = typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
const resolvedWebhook = await resolveWebhookRecordProviderConfig(
webhook,
workflow.userId,
workspaceId
)
await handler.deleteSubscription({
webhook: resolvedWebhook,
workflow,
requestId,
strict: options.throwOnError,
})
} catch (error) {
logger.warn(`[${requestId}] Error cleaning up external webhook (non-fatal)`, {
provider,
+26 -21
View File
@@ -21,7 +21,11 @@ export const gmailHandler: WebhookProviderHandler = {
return { input: b }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
async configurePolling({
webhook: webhookData,
requestId,
persistProviderConfig,
}: PollingConfigContext) {
logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`)
try {
@@ -79,26 +83,27 @@ export const gmailHandler: WebhookProviderHandler = {
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
userId: effectiveUserId,
credentialId,
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
labelIds: providerConfig.labelIds || ['INBOX'],
labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE',
lastCheckedTimestamp:
(providerConfig.lastCheckedTimestamp as string) || now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
const configuredProviderConfig = {
...providerConfig,
userId: effectiveUserId,
credentialId,
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
labelIds: providerConfig.labelIds || ['INBOX'],
labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE',
lastCheckedTimestamp: (providerConfig.lastCheckedTimestamp as string) || now.toISOString(),
setupCompleted: true,
}
if (persistProviderConfig) {
await persistProviderConfig(configuredProviderConfig)
} else {
await db
.update(webhook)
.set({ providerConfig: configuredProviderConfig, updatedAt: now })
.where(eq(webhook.id, webhookData.id as string))
}
logger.info(
`[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}`
@@ -0,0 +1,236 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { grainHandler } from '@/lib/webhooks/providers/grain'
const WEBHOOK_ID = 'webhook-uuid-1234'
const fetchMock = vi.fn()
function makeWebhook(providerConfig: Record<string, unknown>) {
return {
id: WEBHOOK_ID,
path: 'grain-path',
providerConfig,
} as unknown as Parameters<typeof grainHandler.deleteSubscription>[0]['webhook']
}
function jsonResponse(status: number, body: Record<string, unknown>) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function createContext(providerConfig: Record<string, unknown>) {
return {
webhook: makeWebhook(providerConfig),
workflow: {},
userId: 'user-1',
requestId: 'req-1',
} as never
}
describe('grainHandler createSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', fetchMock)
process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com'
})
afterEach(() => {
vi.unstubAllGlobals()
process.env.NEXT_PUBLIC_APP_URL = undefined
})
it('creates the hook for a single-event v2 trigger', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'hook-1' }))
const result = await grainHandler.createSubscription!(
createContext({
apiKey: 'grain-key',
triggerId: 'grain_recording_added_v2',
})
)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.grain.com/_/public-api/v2/hooks/create')
expect(init.headers['Public-Api-Version']).toBe('2025-10-31')
expect(JSON.parse(init.body)).toMatchObject({ hook_type: 'recording_added' })
expect(result?.providerConfigUpdates).toMatchObject({
externalIds: ['hook-1'],
externalId: 'hook-1',
eventTypes: ['recording_added'],
})
})
it('creates one hook per event type for the All Events trigger', async () => {
for (let i = 1; i <= 10; i++) {
fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: `hook-${i}` }))
}
const result = await grainHandler.createSubscription!(
createContext({
apiKey: 'grain-key',
triggerId: 'grain_all_events_v2',
})
)
expect(fetchMock).toHaveBeenCalledTimes(10)
const hookTypes = fetchMock.mock.calls.map((call) => JSON.parse(call[1].body).hook_type)
expect(hookTypes).toEqual([
'recording_added',
'recording_updated',
'recording_deleted',
'highlight_added',
'highlight_updated',
'highlight_deleted',
'story_added',
'story_updated',
'story_deleted',
'upload_status',
])
expect(result?.providerConfigUpdates).toMatchObject({
externalIds: hookTypes.map((_, i) => `hook-${i + 1}`),
externalId: 'hook-1',
})
})
it('keeps legacy view-scoped triggers on the v1 API without remapping', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'legacy-hook-1' }))
const result = await grainHandler.createSubscription!(
createContext({
apiKey: 'grain-key',
triggerId: 'grain_recording_created',
viewId: 'legacy-view',
})
)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.grain.com/_/public-api/hooks')
expect(init.headers['Public-Api-Version']).toBeUndefined()
expect(JSON.parse(init.body)).toMatchObject({
version: 2,
view_id: 'legacy-view',
actions: ['added'],
})
expect(result?.providerConfigUpdates).toEqual({
externalId: 'legacy-hook-1',
eventTypes: ['recording_added'],
})
})
it('still requires a view id for legacy triggers', async () => {
await expect(
grainHandler.createSubscription!(
createContext({ apiKey: 'grain-key', triggerId: 'grain_recording_created' })
)
).rejects.toThrow('Grain view ID is required')
expect(fetchMock).not.toHaveBeenCalled()
})
it('rolls back already-created hooks when a later create fails', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse(200, { id: 'hook-1' }))
.mockResolvedValueOnce(jsonResponse(400, { error: 'bad_request' }))
.mockResolvedValueOnce(jsonResponse(200, { success: true }))
await expect(
grainHandler.createSubscription!(
createContext({
apiKey: 'grain-key',
triggerId: 'grain_all_events_v2',
})
)
).rejects.toThrow('Grain error: bad_request')
const deleteCall = fetchMock.mock.calls[2]
expect(deleteCall[0]).toBe('https://api.grain.com/_/public-api/v2/hooks/hook-1')
expect(deleteCall[1].method).toBe('DELETE')
})
it('rejects when the api key is missing', async () => {
await expect(
grainHandler.createSubscription!(createContext({ triggerId: 'grain_recording_added_v2' }))
).rejects.toThrow('Grain API Key is required')
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('grainHandler deleteSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes every hook recorded in externalIds', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse(200, { success: true }))
.mockResolvedValueOnce(jsonResponse(404, {}))
await grainHandler.deleteSubscription!({
webhook: makeWebhook({
apiKey: 'grain-key',
externalIds: ['hook-1', 'hook-2'],
externalId: 'hook-1',
}),
workflow: {},
requestId: 'req-1',
strict: true,
} as never)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[0][0]).toBe('https://api.grain.com/_/public-api/v2/hooks/hook-1')
expect(fetchMock.mock.calls[1][0]).toBe('https://api.grain.com/_/public-api/v2/hooks/hook-2')
})
it('deletes legacy single-externalId rows through the v1 endpoint', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(200, { success: true }))
await grainHandler.deleteSubscription!({
webhook: makeWebhook({ apiKey: 'grain-key', externalId: 'legacy-hook' }),
workflow: {},
requestId: 'req-1',
strict: true,
} as never)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.grain.com/_/public-api/hooks/legacy-hook')
expect(init.headers['Public-Api-Version']).toBeUndefined()
})
it('throws in strict mode when a delete fails with a server error', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(500, {}))
await expect(
grainHandler.deleteSubscription!({
webhook: makeWebhook({ apiKey: 'grain-key', externalIds: ['hook-1'] }),
workflow: {},
requestId: 'req-1',
strict: true,
} as never)
).rejects.toThrow('Failed to delete 1 Grain webhook(s)')
})
it('is a strict no-op failure when cleanup config is missing', async () => {
await expect(
grainHandler.deleteSubscription!({
webhook: makeWebhook({ apiKey: 'grain-key' }),
workflow: {},
requestId: 'req-1',
strict: true,
} as never)
).rejects.toThrow('Missing Grain externalId for webhook deletion')
expect(fetchMock).not.toHaveBeenCalled()
})
})
+311 -142
View File
@@ -11,9 +11,245 @@ import type {
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { skipByEventTypes } from '@/lib/webhooks/providers/utils'
import { GRAIN_V2_TRIGGER_TO_HOOK_TYPES } from '@/triggers/grain/utils'
const logger = createLogger('WebhookProvider:Grain')
const GRAIN_V2_HOOKS_BASE = 'https://api.grain.com/_/public-api/v2/hooks'
const GRAIN_API_VERSION = '2025-10-31'
function grainErrorMessage(responseBody: Record<string, unknown>): string {
const errors = responseBody.errors as Record<string, string> | undefined
const error = responseBody.error as Record<string, string> | string | undefined
return (
errors?.detail ||
(typeof error === 'object' ? error?.message : undefined) ||
(typeof error === 'string' ? error : undefined) ||
(responseBody.message as string) ||
'Unknown Grain API error'
)
}
function grainUserFacingError(status: number, errorMessage: string): string {
if (status === 401) {
return 'Invalid Grain API Key. Please verify your access token is correct.'
}
if (status === 403) {
return 'Access denied. Please ensure your Grain API Key has appropriate permissions.'
}
if (errorMessage && errorMessage !== 'Unknown Grain API error') {
return `Grain error: ${errorMessage}`
}
return 'Failed to create webhook subscription in Grain'
}
/**
* Creates one Grain v2 hook per requested hook type. The v2 API has no
* multi-event hooks, so a trigger subscribing to several event types owns
* several external hooks their ids are all recorded in `externalIds`.
* Hooks already created on a previous partial attempt are deleted before
* rethrowing so a failed prepare never leaks subscriptions.
*/
async function createGrainV2Hooks(params: {
apiKey: string
notificationUrl: string
hookTypes: string[]
requestId: string
webhookId: string
}): Promise<string[]> {
const { apiKey, notificationUrl, hookTypes, requestId, webhookId } = params
const createdIds: string[] = []
try {
for (const hookType of hookTypes) {
const response = await fetch(`${GRAIN_V2_HOOKS_BASE}/create`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': GRAIN_API_VERSION,
},
body: JSON.stringify({ hook_url: notificationUrl, hook_type: hookType }),
})
const responseBody = (await response.json().catch(() => ({}))) as Record<string, unknown>
if (!response.ok || responseBody.error || responseBody.errors) {
const message = grainErrorMessage(responseBody)
logger.error(
`[${requestId}] Failed to create Grain v2 hook (${hookType}) for webhook ${webhookId}. Status: ${response.status}`,
{ message, response: responseBody }
)
throw new Error(grainUserFacingError(response.status, message))
}
const hookId = responseBody.id as string | undefined
if (!hookId) {
throw new Error(
`Grain webhook (${hookType}) created but no webhook ID was returned in the response.`
)
}
createdIds.push(hookId)
}
return createdIds
} catch (error) {
await Promise.allSettled(
createdIds.map((hookId) =>
deleteGrainV2Hook({ apiKey, hookId, requestId }).catch(() => undefined)
)
)
throw error
}
}
async function deleteGrainV2Hook(params: {
apiKey: string
hookId: string
requestId: string
}): Promise<void> {
const response = await fetch(`${GRAIN_V2_HOOKS_BASE}/${params.hookId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': GRAIN_API_VERSION,
},
})
if (!response.ok && response.status !== 404 && response.status !== 410) {
throw new Error(`Failed to delete Grain webhook ${params.hookId}: ${response.status}`)
}
}
/**
* Legacy v1 view-scoped hook creation, preserved verbatim for triggers created
* before the v2 migration. Not remapped to v2 on purpose: v2 has no view
* scoping, so a silent remap would widen what fires the workflow. When Grain
* sunsets v1 (2026-09-07) these deploys fail with Grain's error and the user
* must reconfigure onto the Grain Events trigger.
*/
async function createLegacyV1Subscription(params: {
apiKey: string
triggerId: string | undefined
viewId: string | undefined
notificationUrl: string
requestId: string
webhookId: string
}): Promise<SubscriptionResult> {
const { apiKey, triggerId, viewId, notificationUrl, requestId, webhookId } = params
if (!viewId) {
logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, {
webhookId,
triggerId,
})
throw new Error(
'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.'
)
}
const actionMap: Record<string, Array<'added' | 'updated' | 'removed'>> = {
grain_item_added: ['added'],
grain_item_updated: ['updated'],
grain_recording_created: ['added'],
grain_recording_updated: ['updated'],
grain_highlight_created: ['added'],
grain_highlight_updated: ['updated'],
grain_story_created: ['added'],
}
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_item_added: [],
grain_item_updated: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_added'],
grain_highlight_updated: ['highlight_updated'],
grain_story_created: ['story_added'],
}
const actions = actionMap[triggerId ?? ''] ?? []
const eventTypes = eventTypeMap[triggerId ?? ''] ?? []
if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) {
logger.warn(
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`,
{ webhookId }
)
}
logger.info(`[${requestId}] Creating legacy Grain v1 webhook`, {
triggerId,
viewId,
actions,
eventTypes,
webhookId,
})
const requestBody: Record<string, unknown> = {
version: 2,
hook_url: notificationUrl,
view_id: viewId,
}
if (actions.length > 0) {
requestBody.actions = actions
}
const grainResponse = await fetch('https://api.grain.com/_/public-api/hooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = (await grainResponse.json().catch(() => ({}))) as Record<string, unknown>
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
const message = grainErrorMessage(responseBody)
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhookId}. Status: ${grainResponse.status}`,
{ message, response: responseBody }
)
throw new Error(grainUserFacingError(grainResponse.status, message))
}
const grainWebhookId = responseBody.id as string | undefined
if (!grainWebhookId) {
logger.error(
`[${requestId}] Grain webhook creation response missing id for webhook ${webhookId}.`,
{ response: responseBody }
)
throw new Error(
'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.'
)
}
logger.info(`[${requestId}] Successfully created webhook in Grain for webhook ${webhookId}.`, {
grainWebhookId,
eventTypes,
})
return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } }
}
async function deleteLegacyV1Hook(params: {
apiKey: string
hookId: string
requestId: string
}): Promise<void> {
const response = await fetch(`https://api.grain.com/_/public-api/hooks/${params.hookId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
},
})
if (!response.ok && response.status !== 404 && response.status !== 410) {
throw new Error(`Failed to delete Grain webhook ${params.hookId}: ${response.status}`)
}
}
export const grainHandler: WebhookProviderHandler = {
handleReachabilityTest(body: unknown, requestId: string) {
const obj = body as Record<string, unknown> | null
@@ -48,149 +284,73 @@ export const grainHandler: WebhookProviderHandler = {
return null
},
/**
* Creates external subscriptions. Each v2 trigger maps to its hook types
* (one v2 hook created per type; All Events owns one hook per type). Legacy
* view-scoped triggers are NOT remapped they keep calling the deprecated
* v1 API unchanged until Grain sunsets it (2026-09-07), at which point their
* deploys fail with Grain's own error and users must move to the v2
* triggers.
*/
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
try {
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.apiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
const viewId = providerConfig.viewId as string | undefined
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
webhookId: webhook.id,
})
throw new Error(
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
'Grain API Key is required. Please provide your Grain access token in the trigger configuration.'
)
}
if (!viewId) {
logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, {
webhookId: webhook.id,
triggerId,
})
throw new Error(
'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.'
)
}
const actionMap: Record<string, Array<'added' | 'updated' | 'removed'>> = {
grain_item_added: ['added'],
grain_item_updated: ['updated'],
grain_recording_created: ['added'],
grain_recording_updated: ['updated'],
grain_highlight_created: ['added'],
grain_highlight_updated: ['updated'],
grain_story_created: ['added'],
}
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_item_added: [],
grain_item_updated: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_added'],
grain_highlight_updated: ['highlight_updated'],
grain_story_created: ['story_added'],
}
const actions = actionMap[triggerId ?? ''] ?? []
const eventTypes = eventTypeMap[triggerId ?? ''] ?? []
if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) {
logger.warn(
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`,
{
webhookId: webhook.id,
}
)
}
logger.info(`[${requestId}] Creating Grain webhook`, {
triggerId,
viewId,
actions,
eventTypes,
webhookId: webhook.id,
})
const notificationUrl = getNotificationUrl(webhook)
const grainApiUrl = 'https://api.grain.com/_/public-api/hooks'
const v2HookTypes =
GRAIN_V2_TRIGGER_TO_HOOK_TYPES[triggerId as keyof typeof GRAIN_V2_TRIGGER_TO_HOOK_TYPES]
if (v2HookTypes) {
const hookTypes = [...v2HookTypes]
logger.info(`[${requestId}] Creating Grain v2 hooks`, {
triggerId,
hookTypes,
webhookId: webhook.id,
})
const requestBody: Record<string, unknown> = {
version: 2,
hook_url: notificationUrl,
view_id: viewId,
}
if (actions.length > 0) {
requestBody.actions = actions
const externalIds = await createGrainV2Hooks({
apiKey,
notificationUrl,
hookTypes,
requestId,
webhookId: webhook.id as string,
})
logger.info(
`[${requestId}] Successfully created ${externalIds.length} Grain hook(s) for webhook ${webhook.id}.`,
{ externalIds, hookTypes }
)
return {
providerConfigUpdates: {
externalIds,
/** First id kept for backward-compatible single-id readers. */
externalId: externalIds[0],
eventTypes: hookTypes,
},
}
}
const grainResponse = await fetch(grainApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
return createLegacyV1Subscription({
apiKey,
triggerId,
viewId: providerConfig.viewId as string | undefined,
notificationUrl,
requestId,
webhookId: webhook.id as string,
})
const responseBody = (await grainResponse.json()) as Record<string, unknown>
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
const errors = responseBody.errors as Record<string, string> | undefined
const error = responseBody.error as Record<string, string> | string | undefined
const errorMessage =
errors?.detail ||
(typeof error === 'object' ? error?.message : undefined) ||
(typeof error === 'string' ? error : undefined) ||
(responseBody.message as string) ||
'Unknown Grain API error'
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhook.id}. Status: ${grainResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
if (grainResponse.status === 401) {
userFriendlyMessage =
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
} else if (grainResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
userFriendlyMessage = `Grain error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
const grainWebhookId = responseBody.id as string | undefined
if (!grainWebhookId) {
logger.error(
`[${requestId}] Grain webhook creation response missing id for webhook ${webhook.id}.`,
{
response: responseBody,
}
)
throw new Error(
'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.'
)
}
logger.info(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhook.id}.`,
{
grainWebhookId,
eventTypes,
}
)
return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } }
} catch (error: unknown) {
const err = error as Error
logger.error(
@@ -204,12 +364,21 @@ export const grainHandler: WebhookProviderHandler = {
}
},
/**
* Deletes every externally created hook. Rows created by the v2 path carry
* `externalIds` (one per hook type) and delete through the v2 endpoint; rows
* created before the migration carry a single `externalId` from the v1 API
* and delete through the v1 endpoint they were created with.
*/
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
const isV2Row = Array.isArray(config.externalIds)
const externalIds = (isV2Row ? (config.externalIds as string[]) : [config.externalId]).filter(
(id): id is string => typeof id === 'string' && id.length > 0
)
if (!apiKey) {
logger.warn(
@@ -219,7 +388,7 @@ export const grainHandler: WebhookProviderHandler = {
return
}
if (!externalId) {
if (externalIds.length === 0) {
logger.warn(
`[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup`
)
@@ -227,25 +396,25 @@ export const grainHandler: WebhookProviderHandler = {
return
}
const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}`
const failures: string[] = []
for (const externalId of externalIds) {
try {
if (isV2Row) {
await deleteGrainV2Hook({ apiKey, hookId: externalId, requestId })
} else {
await deleteLegacyV1Hook({ apiKey, hookId: externalId, requestId })
}
logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`)
} catch (error) {
logger.warn(`[${requestId}] Failed to delete Grain webhook ${externalId} (non-fatal)`, {
error,
})
failures.push(externalId)
}
}
const grainResponse = await fetch(grainApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
})
if (!grainResponse.ok && grainResponse.status !== 404) {
const responseBody = await grainResponse.json().catch(() => ({}))
logger.warn(
`[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`,
{ response: responseBody }
)
if (ctx.strict) throw new Error(`Failed to delete Grain webhook: ${grainResponse.status}`)
} else {
logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`)
if (failures.length > 0 && ctx.strict) {
throw new Error(`Failed to delete ${failures.length} Grain webhook(s)`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error)
+24 -18
View File
@@ -36,7 +36,11 @@ export const imapHandler: WebhookProviderHandler = {
return { input: b }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
async configurePolling({
webhook: webhookData,
requestId,
persistProviderConfig,
}: PollingConfigContext) {
logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`)
try {
@@ -50,23 +54,25 @@ export const imapHandler: WebhookProviderHandler = {
return false
}
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
port: providerConfig.port || '993',
secure: providerConfig.secure !== false,
mailbox: providerConfig.mailbox || 'INBOX',
searchCriteria: providerConfig.searchCriteria || 'UNSEEN',
markAsRead: providerConfig.markAsRead || false,
includeAttachments: providerConfig.includeAttachments !== false,
lastCheckedTimestamp: now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
const configuredProviderConfig = {
...providerConfig,
port: providerConfig.port || '993',
secure: providerConfig.secure !== false,
mailbox: providerConfig.mailbox || 'INBOX',
searchCriteria: providerConfig.searchCriteria || 'UNSEEN',
markAsRead: providerConfig.markAsRead || false,
includeAttachments: providerConfig.includeAttachments !== false,
lastCheckedTimestamp: now.toISOString(),
setupCompleted: true,
}
if (persistProviderConfig) {
await persistProviderConfig(configuredProviderConfig)
} else {
await db
.update(webhook)
.set({ providerConfig: configuredProviderConfig, updatedAt: now })
.where(eq(webhook.id, webhookData.id as string))
}
logger.info(
`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`
+32 -27
View File
@@ -21,7 +21,11 @@ export const outlookHandler: WebhookProviderHandler = {
return { input: b }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
async configurePolling({
webhook: webhookData,
requestId,
persistProviderConfig,
}: PollingConfigContext) {
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
try {
@@ -69,32 +73,33 @@ export const outlookHandler: WebhookProviderHandler = {
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
userId: effectiveUserId,
credentialId,
maxEmailsPerPoll:
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
: (providerConfig.maxEmailsPerPoll as number) || 25,
pollingInterval:
typeof providerConfig.pollingInterval === 'string'
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
: (providerConfig.pollingInterval as number) || 5,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
folderIds: providerConfig.folderIds || ['inbox'],
folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
lastCheckedTimestamp:
(providerConfig.lastCheckedTimestamp as string) || now.toISOString(),
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
const configuredProviderConfig = {
...providerConfig,
userId: effectiveUserId,
credentialId,
maxEmailsPerPoll:
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
: (providerConfig.maxEmailsPerPoll as number) || 25,
pollingInterval:
typeof providerConfig.pollingInterval === 'string'
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
: (providerConfig.pollingInterval as number) || 5,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
folderIds: providerConfig.folderIds || ['inbox'],
folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
lastCheckedTimestamp: (providerConfig.lastCheckedTimestamp as string) || now.toISOString(),
setupCompleted: true,
}
if (persistProviderConfig) {
await persistProviderConfig(configuredProviderConfig)
} else {
await db
.update(webhook)
.set({ providerConfig: configuredProviderConfig, updatedAt: now })
.where(eq(webhook.id, webhookData.id as string))
}
logger.info(
`[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}`
+19 -13
View File
@@ -29,25 +29,31 @@ export const rssHandler: WebhookProviderHandler = {
return { input: b }
},
async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) {
async configurePolling({
webhook: webhookData,
requestId,
persistProviderConfig,
}: PollingConfigContext) {
logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, unknown>) || {}
const now = new Date()
await db
.update(webhook)
.set({
providerConfig: {
...providerConfig,
lastCheckedTimestamp: now.toISOString(),
lastSeenGuids: [],
setupCompleted: true,
},
updatedAt: now,
})
.where(eq(webhook.id, webhookData.id as string))
const configuredProviderConfig = {
...providerConfig,
lastCheckedTimestamp: now.toISOString(),
lastSeenGuids: [],
setupCompleted: true,
}
if (persistProviderConfig) {
await persistProviderConfig(configuredProviderConfig)
} else {
await db
.update(webhook)
.set({ providerConfig: configuredProviderConfig, updatedAt: now })
.where(eq(webhook.id, webhookData.id as string))
}
logger.info(
`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`
+5 -1
View File
@@ -9,7 +9,6 @@ export interface AuthContext {
requestId: string
providerConfig: Record<string, unknown>
}
/** Context for event matching against trigger configuration. */
export interface EventMatchContext {
webhook: Record<string, unknown>
@@ -82,6 +81,11 @@ export interface DeleteSubscriptionContext {
export interface PollingConfigContext {
webhook: Record<string, unknown>
requestId: string
/**
* Stable registration preparation supplies a generation-fenced persistence callback.
* Legacy callers omit it and retain the existing provider-owned write behavior.
*/
persistProviderConfig?(providerConfig: Record<string, unknown>): Promise<boolean>
}
/**
@@ -0,0 +1,132 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
fingerprintDesiredWebhookRegistration,
normalizeWebhookRegistrationPath,
} from '@/lib/webhooks/registration-identity'
function permutations<T>(values: readonly T[]): T[][] {
if (values.length <= 1) return [[...values]]
return values.flatMap((value, index) =>
permutations([...values.slice(0, index), ...values.slice(index + 1)]).map((rest) => [
value,
...rest,
])
)
}
const BASE_IDENTITY = {
provider: 'example',
path: 'events/incoming',
routingKey: 'tenant-1',
} as const
describe('fingerprintDesiredWebhookRegistration', () => {
it('is invariant across deep object key orderings', () => {
const outerEntries = [
['enabled', false],
['filter', { zeta: 0, alpha: '', nested: { second: null, first: 'value' } }],
['topics', [{ beta: 2, alpha: 1 }, 'created']],
] as const
const nestedEntries = [
['zeta', 0],
['alpha', ''],
['nested', { second: null, first: 'value' }],
] as const
const fingerprints = new Set<string>()
for (const outerOrder of permutations(outerEntries)) {
for (const nestedOrder of permutations(nestedEntries)) {
const desiredConfig = Object.fromEntries(outerOrder) as Record<string, unknown>
desiredConfig.filter = Object.fromEntries(nestedOrder)
fingerprints.add(fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, desiredConfig }))
}
}
expect(fingerprints.size).toBe(1)
})
it('is stable as provider-managed and polling state changes outside the desired projection', () => {
const desiredConfig = {
credentialId: 'credential-1',
eventType: 'message.created',
includeThreads: false,
}
const expected = fingerprintDesiredWebhookRegistration({
...BASE_IDENTITY,
desiredConfig,
})
const managedStateVariants = Array.from({ length: 32 }, (_, index) => ({
externalSubscriptionId: `external-${index}`,
historyId: String(10_000 + index),
lastCheckedTimestamp: new Date(1_700_000_000_000 + index * 1000).toISOString(),
lastSeenGuids: [`guid-${index}`],
setupCompleted: index % 2 === 0,
subscriptionExpiration: new Date(1_800_000_000_000 + index * 1000).toISOString(),
}))
for (const managedState of managedStateVariants) {
const persistedProviderConfig = { ...desiredConfig, ...managedState }
expect(persistedProviderConfig).toMatchObject(managedState)
expect(
fingerprintDesiredWebhookRegistration({
...BASE_IDENTITY,
desiredConfig,
})
).toBe(expected)
}
})
it('preserves null, false, zero, empty, undefined, and missing distinctions', () => {
const variants: ReadonlyArray<Record<string, unknown>> = [
{ value: null },
{ value: false },
{ value: 0 },
{ value: '' },
{ value: {} },
{ value: [] },
{ value: undefined },
{},
]
const fingerprints = variants.map((desiredConfig) =>
fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, desiredConfig })
)
expect(new Set(fingerprints).size).toBe(variants.length)
})
it('normalizes equivalent callback paths without collapsing null and empty paths', () => {
const desiredConfig = { eventType: 'created' }
const canonical = fingerprintDesiredWebhookRegistration({
...BASE_IDENTITY,
path: 'events/incoming',
desiredConfig,
})
expect(
fingerprintDesiredWebhookRegistration({
...BASE_IDENTITY,
path: ' /events/incoming/ ',
desiredConfig,
})
).toBe(canonical)
expect(normalizeWebhookRegistrationPath(null)).toBeNull()
expect(normalizeWebhookRegistrationPath(' / ')).toBe('')
expect(
fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, path: null, desiredConfig })
).not.toBe(fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, path: '', desiredConfig }))
})
it('rejects cyclic desired config instead of emitting an unstable fingerprint', () => {
const desiredConfig: Record<string, unknown> = {}
desiredConfig.self = desiredConfig
expect(() =>
fingerprintDesiredWebhookRegistration({ ...BASE_IDENTITY, desiredConfig })
).toThrow('cannot contain cycles')
})
})
@@ -0,0 +1,99 @@
import { sha256Hex } from '@sim/security/hash'
import { isPlainRecord } from '@sim/utils/object'
export interface DesiredWebhookRegistrationIdentity {
provider: string
path: string | null
routingKey: string | null
/**
* The user-controlled projection produced while building the desired trigger configuration.
* Provider-managed subscription metadata and polling cursors must not be included.
*/
desiredConfig: Readonly<Record<string, unknown>>
}
type CanonicalValue =
| ['array', CanonicalValue[]]
| ['bigint', string]
| ['boolean', boolean]
| ['null']
| ['number', string]
| ['object', Array<[string, CanonicalValue]>]
| ['string', string]
| ['undefined']
/** Normalizes a webhook path for desired-registration identity comparisons. */
export function normalizeWebhookRegistrationPath(path: string | null): string | null {
if (path === null) return null
return path.trim().replace(/^\/+|\/+$/g, '')
}
function canonicalizeNumber(value: number): string {
if (Number.isNaN(value)) return 'NaN'
if (value === Number.POSITIVE_INFINITY) return 'Infinity'
if (value === Number.NEGATIVE_INFINITY) return '-Infinity'
if (Object.is(value, -0)) return '-0'
return String(value)
}
function canonicalize(value: unknown, ancestors: Set<object>): CanonicalValue {
if (value === null) return ['null']
if (value === undefined) return ['undefined']
if (typeof value === 'boolean') return ['boolean', value]
if (typeof value === 'number') return ['number', canonicalizeNumber(value)]
if (typeof value === 'string') return ['string', value]
if (typeof value === 'bigint') return ['bigint', value.toString()]
if (Array.isArray(value)) {
if (ancestors.has(value)) {
throw new TypeError('Desired webhook registration config cannot contain cycles')
}
ancestors.add(value)
try {
return ['array', value.map((entry) => canonicalize(entry, ancestors))]
} finally {
ancestors.delete(value)
}
}
if (isPlainRecord(value)) {
if (ancestors.has(value)) {
throw new TypeError('Desired webhook registration config cannot contain cycles')
}
ancestors.add(value)
try {
const entries = Object.keys(value)
.sort()
.map<[string, CanonicalValue]>((key) => [key, canonicalize(value[key], ancestors)])
return ['object', entries]
} finally {
ancestors.delete(value)
}
}
throw new TypeError(
`Unsupported desired webhook registration config value: ${Object.prototype.toString.call(value)}`
)
}
/**
* Returns a deterministic fingerprint for a desired webhook registration.
*
* The caller must pass the explicit user-controlled config projection built from the trigger,
* never a persisted providerConfig row that may contain mutable provider or polling state.
*/
export function fingerprintDesiredWebhookRegistration(
identity: DesiredWebhookRegistrationIdentity
): string {
const canonicalIdentity = canonicalize(
{
provider: identity.provider,
path: normalizeWebhookRegistrationPath(identity.path),
routingKey: identity.routingKey,
desiredConfig: identity.desiredConfig,
},
new Set()
)
return sha256Hex(JSON.stringify(canonicalIdentity))
}
@@ -0,0 +1,158 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
type ExistingWebhookRegistration,
planWebhookRegistrationReconciliation,
} from '@/lib/webhooks/registration-reconciliation'
interface RegistrationRow {
id: string
providerConfig: Record<string, unknown>
}
function existingRegistration(
triggerId: string,
fingerprint: string | null,
generation: number,
providerConfig: Record<string, unknown> = {}
): ExistingWebhookRegistration<RegistrationRow> {
return {
triggerId,
fingerprint,
generation,
row: {
id: `row-${triggerId}`,
providerConfig,
},
}
}
describe('planWebhookRegistrationReconciliation', () => {
it('reuses an unchanged physical row across generations with provider state intact', () => {
const providerConfig = {
credentialId: 'credential-1',
externalSubscriptionId: 'external-1',
historyId: 'history-9',
setupCompleted: true,
}
const existing = existingRegistration('trigger-1', 'fingerprint-1', 4, providerConfig)
const plan = planWebhookRegistrationReconciliation({
generation: 5,
desired: [
{
triggerId: 'trigger-1',
fingerprint: 'fingerprint-1',
desired: { provider: 'example' },
},
],
existing: [existing],
})
expect(plan.actions).toEqual([
expect.objectContaining({
kind: 'reuse',
triggerId: 'trigger-1',
}),
])
const [action] = plan.actions
expect(action.kind).toBe('reuse')
if (action.kind !== 'reuse') throw new Error('Expected reuse action')
expect(action.existing).toBe(existing)
expect(action.existing.row).toBe(existing.row)
expect(action.existing.row.id).toBe('row-trigger-1')
expect(action.existing.row.providerConfig).toBe(providerConfig)
})
it('prepares candidates for changed and missing registrations without mutating current rows', () => {
const changed = existingRegistration('changed', 'old-fingerprint', 7, {
externalId: 'external-1',
})
const before = structuredClone(changed)
const plan = planWebhookRegistrationReconciliation({
generation: 7,
desired: [
{
triggerId: 'changed',
fingerprint: 'new-fingerprint',
desired: { provider: 'example', event: 'updated' },
},
{
triggerId: 'new',
fingerprint: 'new-trigger-fingerprint',
desired: { provider: 'example', event: 'created' },
},
],
existing: [changed],
})
expect(plan.actions).toEqual([
expect.objectContaining({
kind: 'prepare_candidate',
triggerId: 'changed',
existing: changed,
}),
expect.objectContaining({
kind: 'prepare_candidate',
triggerId: 'new',
existing: null,
}),
])
expect(changed).toEqual(before)
})
it('leaves removed-row retirement to the atomic activation step', () => {
const kept = existingRegistration('kept', 'same', 2)
const removed = existingRegistration('removed', 'old', 2)
const plan = planWebhookRegistrationReconciliation({
generation: 3,
desired: [{ triggerId: 'kept', fingerprint: 'same', desired: {} }],
existing: [kept, removed],
})
expect(plan.actions.map((action) => action.kind)).toEqual(['reuse'])
})
it('rejects a stale generation before planning over newer rows', () => {
const newer = existingRegistration('trigger-1', 'same', 11)
expect(() =>
planWebhookRegistrationReconciliation({
generation: 10,
desired: [{ triggerId: 'trigger-1', fingerprint: 'same', desired: {} }],
existing: [newer],
})
).toThrow('newer registration generation 11')
})
it.each([
{
label: 'desired',
desired: [
{ triggerId: 'duplicate', fingerprint: 'one', desired: {} },
{ triggerId: 'duplicate', fingerprint: 'two', desired: {} },
],
existing: [],
},
{
label: 'existing',
desired: [],
existing: [
existingRegistration('duplicate', 'one', 1),
existingRegistration('duplicate', 'two', 1),
],
},
])('rejects duplicate trigger identities in $label registrations', ({ desired, existing }) => {
expect(() =>
planWebhookRegistrationReconciliation({
generation: 1,
desired,
existing,
})
).toThrow('duplicate triggerId "duplicate"')
})
})
@@ -0,0 +1,110 @@
export interface DesiredWebhookRegistration<TDesired = unknown> {
triggerId: string
fingerprint: string
desired: TDesired
}
export interface ExistingWebhookRegistration<TRow extends { id: string }> {
triggerId: string
generation: number
fingerprint: string | null
row: TRow
}
export type WebhookRegistrationReconciliationAction<TDesired, TRow extends { id: string }> =
| {
kind: 'reuse'
triggerId: string
desired: DesiredWebhookRegistration<TDesired>
existing: ExistingWebhookRegistration<TRow>
}
| {
kind: 'prepare_candidate'
triggerId: string
desired: DesiredWebhookRegistration<TDesired>
existing: ExistingWebhookRegistration<TRow> | null
}
export interface WebhookRegistrationReconciliationPlan<TDesired, TRow extends { id: string }> {
actions: Array<WebhookRegistrationReconciliationAction<TDesired, TRow>>
}
function assertGeneration(generation: number, label: string): void {
if (!Number.isSafeInteger(generation) || generation < 0) {
throw new TypeError(`${label} must be a non-negative safe integer`)
}
}
function indexUniqueByTriggerId<T extends { triggerId: string }>(
entries: readonly T[],
label: string
): Map<string, T> {
const entriesByTriggerId = new Map<string, T>()
for (const entry of entries) {
if (!entry.triggerId) {
throw new TypeError(`${label} triggerId cannot be empty`)
}
if (entriesByTriggerId.has(entry.triggerId)) {
throw new TypeError(`${label} contains duplicate triggerId "${entry.triggerId}"`)
}
entriesByTriggerId.set(entry.triggerId, entry)
}
return entriesByTriggerId
}
/**
* Produces the side-effect-free registration work for one deployment generation.
*
* Fingerprint matches reuse the exact persisted row object, retaining its physical ID and
* provider-managed state. Changed or missing registrations prepare candidates, while registrations
* absent from the desired trigger set are retired. A stale generation cannot act on newer rows.
*/
export function planWebhookRegistrationReconciliation<
TDesired,
TRow extends { id: string },
>(input: {
generation: number
desired: readonly DesiredWebhookRegistration<TDesired>[]
existing: readonly ExistingWebhookRegistration<TRow>[]
}): WebhookRegistrationReconciliationPlan<TDesired, TRow> {
assertGeneration(input.generation, 'Reconciliation generation')
indexUniqueByTriggerId(input.desired, 'Desired registrations')
const existingByTriggerId = indexUniqueByTriggerId(input.existing, 'Existing registrations')
for (const existing of input.existing) {
assertGeneration(
existing.generation,
`Existing registration "${existing.triggerId}" generation`
)
if (existing.generation > input.generation) {
throw new Error(
`Cannot reconcile generation ${input.generation} over newer registration generation ${existing.generation} for trigger "${existing.triggerId}"`
)
}
}
const actions: Array<WebhookRegistrationReconciliationAction<TDesired, TRow>> = []
for (const desired of input.desired) {
const existing = existingByTriggerId.get(desired.triggerId)
if (existing?.fingerprint === desired.fingerprint) {
actions.push({
kind: 'reuse',
triggerId: desired.triggerId,
desired,
existing,
})
continue
}
actions.push({
kind: 'prepare_candidate',
triggerId: desired.triggerId,
desired,
existing: existing ?? null,
})
}
return { actions }
}
@@ -0,0 +1,349 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { providerHandler } = vi.hoisted(() => ({
providerHandler: {
createSubscription: vi.fn(),
},
}))
vi.mock('@/lib/webhooks/providers', () => ({
getProviderHandler: vi.fn(() => providerHandler),
}))
import type { NextRequest } from 'next/server'
import {
cleanupRetiredWebhookRegistrationsAfterActivation,
prepareStableWebhookRegistrations,
type StableWebhookRegistrationDependencies,
} from '@/lib/webhooks/registration-service'
import {
buildLegacyInvisibleCandidateValues,
type DesiredWebhookRegistrationIntent,
type WebhookRegistrationOperationFence,
type WebhookRegistrationRow,
} from '@/lib/webhooks/registration-store'
const fence: WebhookRegistrationOperationFence = {
workflowId: 'workflow-1',
operationId: 'operation-5',
generation: 5,
deploymentVersionId: 'version-5',
}
function registrationRow(overrides: Partial<WebhookRegistrationRow> = {}): WebhookRegistrationRow {
return {
id: 'webhook-1',
workflowId: fence.workflowId,
deploymentVersionId: 'version-4',
registrationStatus: 'active',
registrationGeneration: 4,
configFingerprint: 'old-fingerprint',
preparedAt: new Date('2026-01-01T00:00:00Z'),
blockId: 'trigger-1',
path: 'events',
routingKey: null,
provider: 'parallel-provider',
providerConfig: { externalId: 'external-old', cursor: 'cursor-9' },
isActive: true,
failedCount: 3,
lastFailedAt: new Date('2026-01-02T00:00:00Z'),
archivedAt: null,
createdAt: new Date('2025-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-02T00:00:00Z'),
...overrides,
}
}
function dependencies(
overrides: Partial<StableWebhookRegistrationDependencies> = {}
): StableWebhookRegistrationDependencies {
return {
prepareIntents: vi.fn(),
checkpointCandidate: vi.fn(),
listRetired: vi.fn(),
getCleanupSnapshot: vi.fn(),
deleteAfterCleanup: vi.fn(),
createExternal: vi.fn(),
cleanupExternal: vi.fn(),
...overrides,
} as unknown as StableWebhookRegistrationDependencies
}
describe('stable webhook registration service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('persists candidate intent as invisible to legacy delivery queries', () => {
const now = new Date('2026-07-14T00:00:00Z')
const desired: DesiredWebhookRegistrationIntent = {
blockId: 'trigger-1',
provider: 'parallel-provider',
path: '/events/',
routingKey: null,
providerConfig: { event: 'created' },
configFingerprint: 'fingerprint-5',
}
const values = buildLegacyInvisibleCandidateValues({
id: 'candidate-5',
fence,
desired,
now,
})
expect(values).toMatchObject({
registrationStatus: 'candidate',
registrationGeneration: 5,
path: 'events',
isActive: false,
archivedAt: now,
preparedAt: null,
})
})
it('never touches the live subscription when candidate preparation fails', async () => {
const candidate = registrationRow({
id: 'candidate-5',
deploymentVersionId: fence.deploymentVersionId,
registrationStatus: 'candidate',
registrationGeneration: fence.generation,
configFingerprint: 'new-fingerprint',
preparedAt: null,
providerConfig: { event: 'updated' },
isActive: false,
failedCount: 0,
lastFailedAt: null,
archivedAt: new Date('2026-07-14T00:00:00Z'),
})
const createExternal = vi.fn().mockRejectedValue(new Error('provider unavailable'))
const cleanupExternal = vi.fn()
const checkpointCandidate = vi.fn()
const store = dependencies({
prepareIntents: vi.fn().mockResolvedValue({
candidates: [
{
desired: {
blockId: 'trigger-1',
provider: 'parallel-provider',
path: 'events',
routingKey: null,
providerConfig: { event: 'updated' },
configFingerprint: 'new-fingerprint',
},
row: candidate,
},
],
orphanedCandidates: [],
}),
createExternal,
cleanupExternal,
checkpointCandidate,
})
await expect(
prepareStableWebhookRegistrations(
{
request: {} as NextRequest,
fence,
workflow: { id: fence.workflowId },
userId: 'user-1',
requestId: 'request-1',
desired: [
{
blockId: 'trigger-1',
provider: 'parallel-provider',
path: 'events',
routingKey: null,
providerConfig: { event: 'updated' },
desiredConfig: { event: 'updated' },
},
],
},
store
)
).rejects.toThrow('Failed to prepare 1 webhook registration')
expect(createExternal).toHaveBeenCalledTimes(1)
expect(cleanupExternal).not.toHaveBeenCalled()
expect(checkpointCandidate).not.toHaveBeenCalled()
})
it('durably records the external subscription before finishing preparation', async () => {
const candidate = registrationRow({
id: 'candidate-5',
deploymentVersionId: fence.deploymentVersionId,
registrationStatus: 'candidate',
registrationGeneration: fence.generation,
configFingerprint: 'new-fingerprint',
preparedAt: null,
providerConfig: null,
isActive: false,
archivedAt: new Date('2026-07-14T00:00:00Z'),
})
const abortController = new AbortController()
const createExternal = vi.fn().mockResolvedValue({
updatedProviderConfig: { event: 'updated', externalId: 'external-new' },
externalSubscriptionCreated: true,
})
const cleanupExternal = vi.fn()
const checkpointCandidate = vi.fn()
const store = dependencies({
prepareIntents: vi.fn().mockResolvedValue({
candidates: [
{
desired: {
blockId: 'trigger-1',
provider: 'parallel-provider',
path: 'events',
routingKey: null,
providerConfig: { event: 'updated' },
configFingerprint: 'new-fingerprint',
},
row: candidate,
},
],
orphanedCandidates: [],
}),
createExternal,
cleanupExternal,
checkpointCandidate,
})
await prepareStableWebhookRegistrations(
{
request: {} as NextRequest,
fence,
workflow: { id: fence.workflowId },
userId: 'user-1',
requestId: 'request-1',
signal: abortController.signal,
desired: [
{
blockId: 'trigger-1',
provider: 'parallel-provider',
path: 'events',
routingKey: null,
providerConfig: { event: 'updated' },
desiredConfig: { event: 'updated' },
},
],
},
store
)
expect(cleanupExternal).not.toHaveBeenCalled()
expect(createExternal.mock.calls[0][5]).toEqual({ signal: abortController.signal })
expect(checkpointCandidate).toHaveBeenCalledTimes(2)
expect(checkpointCandidate.mock.calls[0][0]).toEqual(
expect.objectContaining({
prepared: false,
providerConfig: { event: 'updated', externalId: 'external-new' },
})
)
expect(checkpointCandidate.mock.calls[1][0]).not.toHaveProperty('prepared')
})
it('cleans a never-prepared ghost candidate best-effort so new deploys are not wedged', async () => {
const ghostOrphan = registrationRow({
id: 'ghost-orphan',
registrationStatus: 'orphaned',
registrationGeneration: 4,
preparedAt: null,
providerConfig: { event: 'created' },
isActive: false,
archivedAt: new Date('2026-07-14T00:00:00Z'),
})
const cleanupExternal = vi.fn()
const deleteAfterCleanup = vi.fn().mockResolvedValue(true)
const checkpointCandidate = vi.fn()
const store = dependencies({
prepareIntents: vi.fn().mockResolvedValue({
candidates: [],
orphanedCandidates: [ghostOrphan],
}),
getCleanupSnapshot: vi.fn().mockResolvedValue(ghostOrphan),
cleanupExternal,
deleteAfterCleanup,
checkpointCandidate,
})
await prepareStableWebhookRegistrations(
{
request: {} as NextRequest,
fence,
workflow: { id: fence.workflowId },
userId: 'user-1',
requestId: 'request-ghost',
desired: [],
},
store
)
expect(cleanupExternal).toHaveBeenCalledWith(ghostOrphan, expect.anything(), 'request-ghost', {
throwOnError: false,
})
expect(deleteAfterCleanup).toHaveBeenCalledTimes(1)
})
it('keeps strict external cleanup for retired rows that served traffic', async () => {
const retired = registrationRow({
registrationStatus: 'retired',
isActive: false,
archivedAt: new Date('2026-07-14T00:00:00Z'),
})
const cleanupExternal = vi.fn()
const deleteAfterCleanup = vi.fn().mockResolvedValue(true)
const store = dependencies({
listRetired: vi.fn().mockResolvedValueOnce([retired]).mockResolvedValue([]),
getCleanupSnapshot: vi.fn().mockResolvedValue(retired),
cleanupExternal,
deleteAfterCleanup,
})
await cleanupRetiredWebhookRegistrationsAfterActivation(
{
fence,
workflow: { id: fence.workflowId },
requestId: 'request-retired',
},
store
)
expect(cleanupExternal).toHaveBeenCalledWith(retired, expect.anything(), 'request-retired', {
throwOnError: true,
})
expect(deleteAfterCleanup).toHaveBeenCalledTimes(1)
})
it('skips external cleanup when the row was reused by a newer generation', async () => {
const staleRetired = registrationRow({
registrationStatus: 'retired',
isActive: false,
archivedAt: new Date(),
})
const cleanupExternal = vi.fn()
const deleteAfterCleanup = vi.fn()
const store = dependencies({
listRetired: vi.fn().mockResolvedValueOnce([staleRetired]).mockResolvedValue([]),
getCleanupSnapshot: vi.fn().mockResolvedValue(null),
cleanupExternal,
deleteAfterCleanup,
})
await cleanupRetiredWebhookRegistrationsAfterActivation(
{
fence,
workflow: { id: fence.workflowId },
requestId: 'request-cleanup',
},
store
)
expect(cleanupExternal).not.toHaveBeenCalled()
expect(deleteAfterCleanup).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,390 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import { getProviderHandler } from '@/lib/webhooks/providers'
import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
import { fingerprintDesiredWebhookRegistration } from '@/lib/webhooks/registration-identity'
import {
checkpointWebhookCandidate,
type DesiredWebhookRegistrationIntent,
deleteWebhookRegistrationAfterCleanup,
getWebhookCleanupSnapshotIfCurrent,
listRetiredWebhookRegistrationsForCleanup,
type PreparedWebhookCandidate,
type PreparedWebhookRegistrationWork,
prepareWebhookRegistrationIntents,
type WebhookRegistrationOperationFence,
type WebhookRegistrationRow,
} from '@/lib/webhooks/registration-store'
const logger = createLogger('StableWebhookRegistration')
export interface StableDesiredWebhookRegistration {
blockId: string
provider: string
path: string | null
routingKey: string | null
providerConfig: Record<string, unknown>
desiredConfig: Readonly<Record<string, unknown>>
}
export interface StableWebhookRegistrationDependencies {
prepareIntents(input: {
fence: WebhookRegistrationOperationFence
desired: readonly DesiredWebhookRegistrationIntent[]
}): Promise<PreparedWebhookRegistrationWork>
checkpointCandidate: typeof checkpointWebhookCandidate
listRetired: typeof listRetiredWebhookRegistrationsForCleanup
getCleanupSnapshot: typeof getWebhookCleanupSnapshotIfCurrent
deleteAfterCleanup: typeof deleteWebhookRegistrationAfterCleanup
createExternal: typeof createExternalWebhookSubscription
cleanupExternal: typeof cleanupExternalWebhook
}
const DEFAULT_DEPENDENCIES: StableWebhookRegistrationDependencies = {
prepareIntents: prepareWebhookRegistrationIntents,
checkpointCandidate: checkpointWebhookCandidate,
listRetired: listRetiredWebhookRegistrationsForCleanup,
getCleanupSnapshot: getWebhookCleanupSnapshotIfCurrent,
deleteAfterCleanup: deleteWebhookRegistrationAfterCleanup,
createExternal: createExternalWebhookSubscription,
cleanupExternal: cleanupExternalWebhook,
}
export interface PrepareStableWebhookRegistrationsInput {
request: NextRequest
fence: WebhookRegistrationOperationFence
workflow: Record<string, unknown>
userId: string
requestId: string
desired: readonly StableDesiredWebhookRegistration[]
signal?: AbortSignal
}
function buildDesiredIntents(
desired: readonly StableDesiredWebhookRegistration[]
): DesiredWebhookRegistrationIntent[] {
return desired.map((registration) => ({
blockId: registration.blockId,
provider: registration.provider,
path: registration.path,
routingKey: registration.routingKey,
providerConfig: registration.providerConfig,
configFingerprint: fingerprintDesiredWebhookRegistration({
provider: registration.provider,
path: registration.path,
routingKey: registration.routingKey,
desiredConfig: registration.desiredConfig,
}),
}))
}
async function cleanupGenerationFencedRegistration(
row: WebhookRegistrationRow,
workflow: Record<string, unknown>,
requestId: string,
statuses: readonly ('candidate' | 'orphaned' | 'retired')[],
dependencies: StableWebhookRegistrationDependencies
): Promise<boolean> {
if (row.registrationGeneration === null) return false
const snapshot = await dependencies.getCleanupSnapshot({
workflowId: row.workflowId,
webhookId: row.id,
expectedGeneration: row.registrationGeneration,
statuses,
})
if (!snapshot) return false
/**
* Strict cleanup only for rows that verifiably held a live subscription:
* retired rows served traffic and prepared candidates completed provider
* setup. A never-prepared ghost (its create failed) has partial or no
* external state strict-failing on it would wedge every future deploy
* behind an uncleanable leftover, so those clean up best-effort instead.
*/
const holdsVerifiedSubscription =
snapshot.registrationStatus === 'retired' || snapshot.preparedAt !== null
await dependencies.cleanupExternal(snapshot, workflow, requestId, {
throwOnError: holdsVerifiedSubscription,
})
return dependencies.deleteAfterCleanup({
workflowId: snapshot.workflowId,
webhookId: snapshot.id,
expectedGeneration: row.registrationGeneration,
statuses,
})
}
async function createCandidateProviderState(
input: PrepareStableWebhookRegistrationsInput,
candidate: PreparedWebhookCandidate,
dependencies: StableWebhookRegistrationDependencies
): Promise<Record<string, unknown>> {
const webhookData = {
...candidate.row,
provider: candidate.desired.provider,
providerConfig: candidate.row.providerConfig ?? candidate.desired.providerConfig,
}
const handler = getProviderHandler(candidate.desired.provider)
const externalResult = await dependencies.createExternal(
input.request,
webhookData,
input.workflow,
input.userId,
input.requestId,
{ signal: input.signal }
)
let providerConfig = externalResult.updatedProviderConfig
if (externalResult.externalSubscriptionCreated) {
/**
* Persist the provider-returned state immediately so the external
* subscription is never unrecorded: if the lease aborts or the process
* dies between here and the final checkpoint, the retry (or orphan
* cleanup) can delete this subscription from the row instead of leaking
* it and creating a duplicate.
*/
await dependencies.checkpointCandidate({
fence: input.fence,
webhookId: candidate.row.id,
providerConfig,
prepared: false,
})
}
if (handler.configurePolling) {
let persistedProviderConfig: Record<string, unknown> | undefined
const configured = await handler.configurePolling({
webhook: { ...webhookData, providerConfig },
requestId: input.requestId,
persistProviderConfig: async (configuredProviderConfig) => {
persistedProviderConfig = configuredProviderConfig
await dependencies.checkpointCandidate({
fence: input.fence,
webhookId: candidate.row.id,
providerConfig: configuredProviderConfig,
prepared: false,
})
return true
},
})
if (!configured) {
throw new Error(`Failed to configure ${candidate.desired.provider} polling`)
}
if (persistedProviderConfig) {
providerConfig = persistedProviderConfig
} else {
const configuredRow = await dependencies.getCleanupSnapshot({
workflowId: input.fence.workflowId,
webhookId: candidate.row.id,
expectedGeneration: input.fence.generation,
statuses: ['candidate'],
})
if (!configuredRow) {
throw new Error('Webhook candidate became stale while configuring polling')
}
if (configuredRow.providerConfig && typeof configuredRow.providerConfig === 'object') {
providerConfig = configuredRow.providerConfig as Record<string, unknown>
}
}
}
return providerConfig
}
/**
* Prepares one candidate registration without ever touching the currently
* serving external subscription: the candidate's subscription is created
* alongside the live one, and the old subscription is deleted only after
* activation retires its row (cleanupRetiredWebhookRegistrationsAfterActivation).
* A failed or superseded attempt therefore leaves live delivery intact the
* candidate's own external state is rolled back or garbage-collected as an
* orphan on the next preparation.
*
* Providers with singleton registrations per credential (e.g. Telegram
* setWebhook) implicitly repoint on create; their delete handlers already
* skip teardown while an active deployment uses the same credential, so the
* retired-row cleanup after cutover cannot disturb the new subscription.
*/
async function prepareCandidate(
input: PrepareStableWebhookRegistrationsInput,
candidate: PreparedWebhookCandidate,
dependencies: StableWebhookRegistrationDependencies
): Promise<void> {
if (candidate.row.preparedAt) return
input.signal?.throwIfAborted()
const handler: WebhookProviderHandler = getProviderHandler(candidate.desired.provider)
const hasProviderPreparation = Boolean(handler.createSubscription || handler.configurePolling)
if (!hasProviderPreparation) {
await dependencies.checkpointCandidate({
fence: input.fence,
webhookId: candidate.row.id,
providerConfig: candidate.desired.providerConfig,
})
return
}
const verificationTracker = new PendingWebhookVerificationTracker()
let preparedProviderConfig: Record<string, unknown> | undefined
try {
if (candidate.row.path) {
await verificationTracker.register({
path: candidate.row.path,
provider: candidate.desired.provider,
workflowId: input.fence.workflowId,
blockId: candidate.desired.blockId,
metadata: candidate.desired.providerConfig,
})
}
input.signal?.throwIfAborted()
preparedProviderConfig = await createCandidateProviderState(input, candidate, dependencies)
input.signal?.throwIfAborted()
await dependencies.checkpointCandidate({
fence: input.fence,
webhookId: candidate.row.id,
providerConfig: preparedProviderConfig,
})
} catch (error) {
if (preparedProviderConfig) {
try {
const currentCandidate = await dependencies.getCleanupSnapshot({
workflowId: input.fence.workflowId,
webhookId: candidate.row.id,
expectedGeneration: input.fence.generation,
statuses: ['candidate'],
})
if (currentCandidate) {
await dependencies.cleanupExternal(
{ ...currentCandidate, providerConfig: preparedProviderConfig },
input.workflow,
input.requestId,
{ throwOnError: true }
)
}
} catch (cleanupError) {
logger.error('Failed to rollback an uncheckpointed webhook candidate', {
workflowId: input.fence.workflowId,
webhookId: candidate.row.id,
error: toError(cleanupError).message,
})
}
}
throw error
} finally {
await verificationTracker.clearAll()
}
}
/**
* Prepares each registration action independently while leaving the currently active set untouched.
*/
export async function prepareStableWebhookRegistrations(
input: PrepareStableWebhookRegistrationsInput,
dependencies: StableWebhookRegistrationDependencies = DEFAULT_DEPENDENCIES
): Promise<void> {
input.signal?.throwIfAborted()
const work = await dependencies.prepareIntents({
fence: input.fence,
desired: buildDesiredIntents(input.desired),
})
const failures: Error[] = []
const failureReasons: string[] = []
const blocksWithFailedOrphanCleanup = new Set<string>()
for (const orphaned of work.orphanedCandidates) {
try {
await cleanupGenerationFencedRegistration(
orphaned,
input.workflow,
input.requestId,
['orphaned'],
dependencies
)
} catch (error) {
failures.push(toError(error))
failureReasons.push(`${orphaned.provider ?? 'webhook'} cleanup: ${toError(error).message}`)
if (orphaned.blockId) blocksWithFailedOrphanCleanup.add(orphaned.blockId)
}
}
for (const candidate of work.candidates) {
if (blocksWithFailedOrphanCleanup.has(candidate.desired.blockId)) continue
try {
await prepareCandidate(input, candidate, dependencies)
} catch (error) {
failures.push(toError(error))
failureReasons.push(`${candidate.desired.provider}: ${toError(error).message}`)
logger.warn('Webhook registration candidate preparation failed', {
workflowId: input.fence.workflowId,
webhookId: candidate.row.id,
provider: candidate.desired.provider,
error: toError(error).message,
})
}
}
if (failures.length > 0) {
/**
* The aggregate message carries the underlying provider reasons because it
* is what gets persisted on the deployment operation and shown in the
* retrying/failed tooltips a bare count would hide the actual cause.
*/
throw new AggregateError(
failures,
`Failed to prepare ${failures.length} webhook registration(s): ${[...new Set(failureReasons)].join('; ')}`
)
}
}
/**
* Cleans retired provider resources after activation without trusting stale cleanup payloads.
*/
export async function cleanupRetiredWebhookRegistrationsAfterActivation(
input: {
fence: WebhookRegistrationOperationFence
workflow: Record<string, unknown>
requestId: string
signal?: AbortSignal
},
dependencies: StableWebhookRegistrationDependencies = DEFAULT_DEPENDENCIES
): Promise<void> {
while (true) {
input.signal?.throwIfAborted()
const retiredRows = await dependencies.listRetired({ ...input.fence, limit: 100 })
if (retiredRows.length === 0) return
const failures: Error[] = []
const failureReasons: string[] = []
for (const row of retiredRows) {
input.signal?.throwIfAborted()
try {
await cleanupGenerationFencedRegistration(
row,
input.workflow,
input.requestId,
['retired'],
dependencies
)
} catch (error) {
failures.push(toError(error))
failureReasons.push(`${row.provider ?? 'webhook'}: ${toError(error).message}`)
}
}
if (failures.length > 0) {
throw new AggregateError(
failures,
`Failed to clean ${failures.length} retired webhook(s): ${[...new Set(failureReasons)].join('; ')}`
)
}
}
}
@@ -0,0 +1,337 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface Condition {
kind: string
column?: unknown
value?: unknown
conditions?: Condition[]
}
const { mockTransaction, mockIsDeploymentOperationCurrent, mockClaimWebhookPath } = vi.hoisted(
() => ({
mockTransaction: vi.fn(),
mockIsDeploymentOperationCurrent: vi.fn(),
mockClaimWebhookPath: vi.fn(),
})
)
vi.mock('@sim/db', () => ({
db: { transaction: mockTransaction },
}))
vi.mock('drizzle-orm', () => ({
and: (...conditions: Condition[]) => ({ kind: 'and', conditions }),
eq: (column: unknown, value: unknown) => ({ kind: 'eq', column, value }),
gt: (column: unknown, value: unknown) => ({ kind: 'gt', column, value }),
inArray: (column: unknown, value: unknown) => ({ kind: 'inArray', column, value }),
isNull: (column: unknown) => ({ kind: 'isNull', column }),
lt: (column: unknown, value: unknown) => ({ kind: 'lt', column, value }),
lte: (column: unknown, value: unknown) => ({ kind: 'lte', column, value }),
}))
vi.mock('@/lib/webhooks/provider-subscriptions', () => ({
projectDesiredWebhookProviderConfig: (config: Record<string, unknown>) => config,
}))
vi.mock('@/lib/webhooks/path-claims', () => ({
claimWebhookPath: mockClaimWebhookPath,
}))
vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({
isDeploymentOperationCurrent: mockIsDeploymentOperationCurrent,
}))
import type { DbOrTx } from '@sim/workflow-persistence/types'
import {
activateWebhookRegistrations,
prepareWebhookRegistrationIntents,
StaleWebhookRegistrationOperationError,
type WebhookRegistrationOperationFence,
} from '@/lib/webhooks/registration-store'
const FENCE: WebhookRegistrationOperationFence = {
workflowId: 'workflow-1',
operationId: 'operation-1',
generation: 3,
deploymentVersionId: 'version-3',
}
interface UpdateCall {
payload: Record<string, unknown>
condition: Condition
}
interface InsertCall {
values: Record<string, unknown>
}
/**
* Queue-driven transaction mock: every select drains the next result from the
* queue regardless of terminal call shape (`for`, `limit`, direct await), and
* updates/inserts capture their payloads for assertions.
*/
function createTx(selectResults: unknown[][]) {
const updates: UpdateCall[] = []
const inserts: InsertCall[] = []
const updateResults: unknown[][] = []
const nextSelect = () => {
const result = selectResults.shift()
if (!result) throw new Error('Unexpected select: result queue is empty')
return result
}
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => {
const terminal = (result: unknown[]) => ({
for: vi.fn(async () => result),
limit: vi.fn(async () => result),
orderBy: vi.fn(() => ({ limit: vi.fn(async () => result) })),
then: (resolve: (rows: unknown[]) => void) => resolve(result),
})
return {
where: vi.fn(() => terminal(nextSelect())),
}
}),
})),
update: vi.fn(() => ({
set: vi.fn((payload: Record<string, unknown>) => ({
where: vi.fn((condition: Condition) => {
updates.push({ payload, condition })
const result = updateResults.shift() ?? [{ id: 'updated' }]
return {
returning: vi.fn(async () => result),
then: (resolve: (rows: unknown[]) => void) => resolve(result),
}
}),
})),
})),
insert: vi.fn(() => ({
values: vi.fn((values: Record<string, unknown>) => {
inserts.push({ values })
return { returning: vi.fn(async () => [{ ...values }]) }
}),
})),
}
return { tx: tx as unknown as DbOrTx, updates, inserts, updateResults }
}
function activeRow(overrides: Record<string, unknown> = {}) {
return {
id: 'wh-active',
workflowId: 'workflow-1',
blockId: 'block-1',
provider: 'slack',
path: 'hooks/a',
routingKey: null,
providerConfig: {},
registrationStatus: 'active',
registrationGeneration: 2,
configFingerprint: 'fp-old',
preparedAt: new Date('2026-07-01T00:00:00Z'),
isActive: true,
archivedAt: null,
deploymentVersionId: 'version-2',
updatedAt: new Date('2026-07-01T00:00:00Z'),
createdAt: new Date('2026-07-01T00:00:00Z'),
...overrides,
}
}
describe('activateWebhookRegistrations', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
})
it('rejects when candidates are not fully prepared', async () => {
const { tx, updates } = createTx([[{ id: 'workflow-1' }], [{ id: 'wh-unprepared' }]])
await expect(activateWebhookRegistrations(tx, FENCE)).rejects.toThrow(
'Webhook registration candidates are not fully prepared'
)
expect(updates).toHaveLength(0)
})
it('rejects stale operations when newer generation rows exist', async () => {
const { tx, updates } = createTx([[{ id: 'workflow-1' }], [], [{ id: 'wh-newer' }]])
await expect(activateWebhookRegistrations(tx, FENCE)).rejects.toBeInstanceOf(
StaleWebhookRegistrationOperationError
)
expect(updates).toHaveLength(0)
})
it('rejects when the operation is no longer current', async () => {
mockIsDeploymentOperationCurrent.mockResolvedValue(false)
const { tx, updates } = createTx([[{ id: 'workflow-1' }]])
await expect(activateWebhookRegistrations(tx, FENCE)).rejects.toBeInstanceOf(
StaleWebhookRegistrationOperationError
)
expect(updates).toHaveLength(0)
})
it('retires older actives, repoints reused rows, and promotes candidates atomically', async () => {
const { tx, updates } = createTx([[{ id: 'workflow-1' }], [], []])
await activateWebhookRegistrations(tx, FENCE)
expect(updates).toHaveLength(3)
expect(updates[0].payload).toEqual(
expect.objectContaining({ registrationStatus: 'retired', isActive: false })
)
expect(updates[0].payload.archivedAt).toBeInstanceOf(Date)
expect(JSON.stringify(updates[0].condition)).toContain('"lt"')
expect(updates[1].payload).toEqual(
expect.objectContaining({
deploymentVersionId: 'version-3',
isActive: true,
archivedAt: null,
})
)
expect(updates[2].payload).toEqual(
expect.objectContaining({
registrationStatus: 'active',
deploymentVersionId: 'version-3',
isActive: true,
archivedAt: null,
})
)
})
})
describe('prepareWebhookRegistrationIntents', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
mockClaimWebhookPath.mockResolvedValue('hooks/a')
mockTransaction.mockImplementation(async (callback: (tx: DbOrTx) => Promise<unknown>) => {
throw new Error('mockTransaction not configured for this test')
})
})
function runInTx(selectResults: unknown[][]) {
const harness = createTx(selectResults)
mockTransaction.mockImplementation(async (callback: (tx: DbOrTx) => Promise<unknown>) =>
callback(harness.tx)
)
return harness
}
const desired = {
blockId: 'block-1',
provider: 'slack',
path: 'hooks/a',
routingKey: null,
providerConfig: { url: 'https://example.test' },
configFingerprint: 'fp-new',
}
it('claims paths and writes legacy-invisible candidates for changed registrations', async () => {
const previousActive = activeRow()
const { inserts, updates } = runInTx([[{ id: 'workflow-1' }], [], [previousActive], [], []])
const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] })
expect(mockClaimWebhookPath).toHaveBeenCalledWith(expect.anything(), {
path: 'hooks/a',
workflowId: 'workflow-1',
generation: 3,
})
expect(updates).toHaveLength(0)
expect(inserts).toHaveLength(1)
expect(inserts[0].values).toEqual(
expect.objectContaining({
registrationStatus: 'candidate',
registrationGeneration: 3,
configFingerprint: 'fp-new',
isActive: false,
preparedAt: null,
deploymentVersionId: 'version-3',
})
)
expect(inserts[0].values.archivedAt).toBeInstanceOf(Date)
expect(work.candidates).toHaveLength(1)
expect(work.candidates[0].row.blockId).toBe('block-1')
})
it('reuses fingerprint-matched active rows by bumping their generation fence', async () => {
const reusable = activeRow({ configFingerprint: 'fp-new' })
const { inserts, updates } = runInTx([[{ id: 'workflow-1' }], [], [reusable], [], []])
const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] })
expect(inserts).toHaveLength(0)
expect(updates).toHaveLength(1)
expect(updates[0].payload).toEqual(
expect.objectContaining({ registrationGeneration: 3, configFingerprint: 'fp-new' })
)
expect(work.candidates).toHaveLength(0)
})
it('adopts a fingerprint-identical candidate from a superseded attempt instead of reinserting', async () => {
const supersededCandidate = activeRow({
id: 'wh-prev-candidate',
registrationStatus: 'candidate',
registrationGeneration: 2,
configFingerprint: 'fp-new',
preparedAt: null,
isActive: false,
deploymentVersionId: 'version-2',
archivedAt: new Date('2026-07-14T00:00:00Z'),
})
const { inserts, updates } = runInTx([
[{ id: 'workflow-1' }],
[],
[],
[supersededCandidate],
[],
])
const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] })
expect(inserts).toHaveLength(0)
expect(updates).toHaveLength(1)
expect(updates[0].payload).toEqual(
expect.objectContaining({ registrationGeneration: 3, deploymentVersionId: 'version-3' })
)
expect(work.candidates).toHaveLength(1)
expect(work.orphanedCandidates).toHaveLength(0)
})
it('re-collects stale orphans from earlier attempts so they cannot leak forever', async () => {
const staleOrphan = activeRow({
id: 'wh-stale-orphan',
registrationStatus: 'orphaned',
registrationGeneration: 2,
preparedAt: null,
isActive: false,
archivedAt: new Date('2026-07-13T00:00:00Z'),
})
const { inserts } = runInTx([[{ id: 'workflow-1' }], [], [], [], [staleOrphan]])
const work = await prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] })
expect(work.orphanedCandidates).toEqual([staleOrphan])
expect(inserts).toHaveLength(1)
})
it('rejects stale generations before touching rows', async () => {
mockIsDeploymentOperationCurrent.mockResolvedValue(false)
const { inserts, updates } = runInTx([[{ id: 'workflow-1' }]])
await expect(
prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] })
).rejects.toBeInstanceOf(StaleWebhookRegistrationOperationError)
expect(inserts).toHaveLength(0)
expect(updates).toHaveLength(0)
})
})
+591
View File
@@ -0,0 +1,591 @@
import { db } from '@sim/db'
import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { generateShortId } from '@sim/utils/id'
import { isPlainRecord } from '@sim/utils/object'
import type { DbOrTx } from '@sim/workflow-persistence/types'
import { and, eq, gt, inArray, isNull, lt, lte } from 'drizzle-orm'
import { claimWebhookPath } from '@/lib/webhooks/path-claims'
import { projectDesiredWebhookProviderConfig } from '@/lib/webhooks/provider-subscriptions'
import {
fingerprintDesiredWebhookRegistration,
normalizeWebhookRegistrationPath,
} from '@/lib/webhooks/registration-identity'
import { planWebhookRegistrationReconciliation } from '@/lib/webhooks/registration-reconciliation'
import type { DeploymentOperationStatus } from '@/lib/workflows/deployment-lifecycle'
import { isDeploymentOperationCurrent } from '@/lib/workflows/persistence/deployment-operations'
export type WebhookRegistrationRow = typeof webhook.$inferSelect
export type WebhookRegistrationStatus = 'active' | 'candidate' | 'retired' | 'orphaned'
export interface WebhookRegistrationOperationFence {
workflowId: string
operationId: string
generation: number
deploymentVersionId: string
}
export interface DesiredWebhookRegistrationIntent {
blockId: string
provider: string
path: string | null
routingKey: string | null
providerConfig: Record<string, unknown>
configFingerprint: string
}
export interface PreparedWebhookCandidate {
desired: DesiredWebhookRegistrationIntent
row: WebhookRegistrationRow
}
export interface PreparedWebhookRegistrationWork {
candidates: PreparedWebhookCandidate[]
orphanedCandidates: WebhookRegistrationRow[]
}
export class StaleWebhookRegistrationOperationError extends Error {
readonly code = 'stale_webhook_registration_operation'
constructor(message = 'Webhook registration operation is stale') {
super(message)
this.name = 'StaleWebhookRegistrationOperationError'
}
}
function assertOperationGeneration(generation: number): void {
if (!Number.isSafeInteger(generation) || generation <= 0) {
throw new TypeError('Webhook registration generation must be a positive safe integer')
}
}
async function assertCurrentOperation(
tx: DbOrTx,
fence: WebhookRegistrationOperationFence,
allowedStatuses: readonly DeploymentOperationStatus[]
): Promise<void> {
assertOperationGeneration(fence.generation)
const [workflowRow] = await tx
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, fence.workflowId))
.for('update')
if (!workflowRow) {
throw new StaleWebhookRegistrationOperationError('Webhook registration workflow is missing')
}
const isCurrent = await isDeploymentOperationCurrent(
{
workflowId: fence.workflowId,
operationId: fence.operationId,
generation: fence.generation,
deploymentVersionId: fence.deploymentVersionId,
statuses: allowedStatuses,
},
tx
)
if (!isCurrent) {
throw new StaleWebhookRegistrationOperationError()
}
}
function rowProviderConfig(row: WebhookRegistrationRow): Record<string, unknown> {
return isPlainRecord(row.providerConfig) ? row.providerConfig : {}
}
function rowRegistrationGeneration(row: WebhookRegistrationRow): number {
if (
row.registrationGeneration === null ||
!Number.isSafeInteger(row.registrationGeneration) ||
row.registrationGeneration < 0
) {
throw new StaleWebhookRegistrationOperationError(
`Webhook registration ${row.id} has no valid generation`
)
}
return row.registrationGeneration
}
function fingerprintPersistedWebhook(row: WebhookRegistrationRow): string {
if (!row.provider) {
throw new Error(`Webhook registration ${row.id} has no provider`)
}
return fingerprintDesiredWebhookRegistration({
provider: row.provider,
path: row.path,
routingKey: row.routingKey,
desiredConfig: projectDesiredWebhookProviderConfig(rowProviderConfig(row)),
})
}
async function adoptLegacyActiveRows(
tx: DbOrTx,
fence: WebhookRegistrationOperationFence
): Promise<void> {
const [activeVersion] = await tx
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, fence.workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
if (!activeVersion) return
const legacyRows = await tx
.select()
.from(webhook)
.where(
and(
eq(webhook.workflowId, fence.workflowId),
eq(webhook.deploymentVersionId, activeVersion.id),
isNull(webhook.registrationStatus),
eq(webhook.isActive, true),
isNull(webhook.archivedAt)
)
)
const adoptedGeneration = fence.generation - 1
for (const row of legacyRows) {
if (!row.blockId || !row.provider) continue
if (row.path) {
await claimWebhookPath(tx, {
path: row.path,
workflowId: fence.workflowId,
generation: adoptedGeneration,
})
}
const [adopted] = await tx
.update(webhook)
.set({
registrationStatus: 'active',
registrationGeneration: adoptedGeneration,
configFingerprint: fingerprintPersistedWebhook(row),
preparedAt: row.updatedAt,
})
.where(and(eq(webhook.id, row.id), isNull(webhook.registrationStatus)))
.returning({ id: webhook.id })
if (!adopted) {
throw new StaleWebhookRegistrationOperationError(
`Legacy webhook registration ${row.id} changed during adoption`
)
}
}
}
/**
* Builds the insert shape that keeps a candidate invisible to every legacy delivery query.
*/
export function buildLegacyInvisibleCandidateValues(input: {
id: string
fence: WebhookRegistrationOperationFence
desired: DesiredWebhookRegistrationIntent
now: Date
}) {
return {
id: input.id,
workflowId: input.fence.workflowId,
deploymentVersionId: input.fence.deploymentVersionId,
registrationStatus: 'candidate' as const,
registrationGeneration: input.fence.generation,
configFingerprint: input.desired.configFingerprint,
preparedAt: null,
blockId: input.desired.blockId,
path: normalizeWebhookRegistrationPath(input.desired.path),
routingKey: input.desired.routingKey,
provider: input.desired.provider,
providerConfig: input.desired.providerConfig,
isActive: false,
failedCount: 0,
archivedAt: input.now,
createdAt: input.now,
updatedAt: input.now,
}
}
/**
* Persists one generation's registration intent before any provider call is made.
*/
export async function prepareWebhookRegistrationIntents(input: {
fence: WebhookRegistrationOperationFence
desired: readonly DesiredWebhookRegistrationIntent[]
}): Promise<PreparedWebhookRegistrationWork> {
return db.transaction(async (tx) => {
await assertCurrentOperation(tx, input.fence, ['preparing'])
await adoptLegacyActiveRows(tx, input.fence)
for (const desired of input.desired) {
if (desired.path) {
await claimWebhookPath(tx, {
path: desired.path,
workflowId: input.fence.workflowId,
generation: input.fence.generation,
})
}
}
const activeRows = await tx
.select()
.from(webhook)
.where(
and(
eq(webhook.workflowId, input.fence.workflowId),
eq(webhook.registrationStatus, 'active'),
eq(webhook.isActive, true),
isNull(webhook.archivedAt)
)
)
const activeRegistrations = activeRows
.filter(
(row): row is WebhookRegistrationRow & { blockId: string } =>
typeof row.blockId === 'string'
)
.map((row) => ({
triggerId: row.blockId,
generation: rowRegistrationGeneration(row),
fingerprint: row.configFingerprint,
row,
}))
const plan = planWebhookRegistrationReconciliation({
generation: input.fence.generation,
desired: input.desired.map((desired) => ({
triggerId: desired.blockId,
fingerprint: desired.configFingerprint,
desired,
})),
existing: activeRegistrations,
})
const candidateRows = await tx
.select()
.from(webhook)
.where(
and(
eq(webhook.workflowId, input.fence.workflowId),
eq(webhook.registrationStatus, 'candidate')
)
)
const candidatesByBlockId = new Map(
candidateRows
.filter(
(row): row is WebhookRegistrationRow & { blockId: string } =>
typeof row.blockId === 'string'
)
.map((row) => [row.blockId, row])
)
/**
* Orphans left by earlier attempts (their cleanup failed or the process
* died) are re-collected on every preparation so they cannot leak forever
* cleanup itself stays generation-fenced, so racing operations at most
* duplicate a best-effort provider delete.
*/
const staleOrphanRows = await tx
.select()
.from(webhook)
.where(
and(
eq(webhook.workflowId, input.fence.workflowId),
eq(webhook.registrationStatus, 'orphaned')
)
)
const candidates: PreparedWebhookCandidate[] = []
const orphanedCandidates: WebhookRegistrationRow[] = [...staleOrphanRows]
const now = new Date()
for (const action of plan.actions) {
if (action.kind === 'reuse') {
const currentGeneration = rowRegistrationGeneration(action.existing.row)
const [updated] = await tx
.update(webhook)
.set({
registrationGeneration: input.fence.generation,
configFingerprint: action.desired.fingerprint,
preparedAt: now,
updatedAt: now,
})
.where(
and(
eq(webhook.id, action.existing.row.id),
eq(webhook.registrationStatus, 'active'),
eq(webhook.registrationGeneration, currentGeneration),
lte(webhook.registrationGeneration, input.fence.generation)
)
)
.returning()
if (!updated) throw new StaleWebhookRegistrationOperationError()
continue
}
const desired = action.desired.desired
const existingCandidate = candidatesByBlockId.get(action.triggerId)
if (existingCandidate && existingCandidate.configFingerprint === action.desired.fingerprint) {
if (existingCandidate.registrationGeneration === input.fence.generation) {
candidates.push({ desired, row: existingCandidate })
continue
}
/**
* A fingerprint-identical candidate from a superseded attempt is
* adopted rather than orphaned and reinserted: this preserves any
* checkpointed provider progress (an external subscription it already
* created keeps serving instead of being deleted and recreated) and
* avoids insert churn against the path uniqueness index.
*/
const [adopted] = await tx
.update(webhook)
.set({
registrationGeneration: input.fence.generation,
deploymentVersionId: input.fence.deploymentVersionId,
updatedAt: now,
})
.where(
and(
eq(webhook.id, existingCandidate.id),
eq(webhook.registrationStatus, 'candidate'),
eq(webhook.registrationGeneration, rowRegistrationGeneration(existingCandidate))
)
)
.returning()
if (!adopted) throw new StaleWebhookRegistrationOperationError()
candidates.push({ desired, row: adopted })
continue
}
if (existingCandidate) {
const existingGeneration = rowRegistrationGeneration(existingCandidate)
const [orphaned] = await tx
.update(webhook)
.set({
registrationStatus: 'orphaned',
updatedAt: now,
})
.where(
and(
eq(webhook.id, existingCandidate.id),
eq(webhook.registrationStatus, 'candidate'),
eq(webhook.registrationGeneration, existingGeneration)
)
)
.returning()
if (!orphaned) throw new StaleWebhookRegistrationOperationError()
orphanedCandidates.push(orphaned)
}
const [candidate] = await tx
.insert(webhook)
.values(
buildLegacyInvisibleCandidateValues({
id: generateShortId(),
fence: input.fence,
desired,
now,
})
)
.returning()
if (!candidate) throw new Error('Failed to persist webhook registration candidate')
candidates.push({ desired, row: candidate })
}
return { candidates, orphanedCandidates }
})
}
/** Checkpoints provider-managed candidate state under the operation and generation fences. */
export async function checkpointWebhookCandidate(input: {
fence: WebhookRegistrationOperationFence
webhookId: string
providerConfig: Record<string, unknown>
prepared?: boolean
}): Promise<WebhookRegistrationRow> {
return db.transaction(async (tx) => {
await assertCurrentOperation(tx, input.fence, ['preparing'])
const now = new Date()
const [updated] = await tx
.update(webhook)
.set({
providerConfig: input.providerConfig,
...(input.prepared === false ? {} : { preparedAt: now }),
updatedAt: now,
})
.where(
and(
eq(webhook.id, input.webhookId),
eq(webhook.workflowId, input.fence.workflowId),
eq(webhook.registrationStatus, 'candidate'),
eq(webhook.registrationGeneration, input.fence.generation)
)
)
.returning()
if (!updated) throw new StaleWebhookRegistrationOperationError()
return updated
})
}
/**
* Atomically promotes prepared candidates, repoints reused rows, and retires superseded rows.
*
* This is designed to be passed directly to a v2 deployment operation's activation transaction.
*/
export async function activateWebhookRegistrations(
tx: DbOrTx,
fence: WebhookRegistrationOperationFence
): Promise<void> {
await assertCurrentOperation(tx, fence, ['active'])
const unpreparedCandidates = await tx
.select({ id: webhook.id })
.from(webhook)
.where(
and(
eq(webhook.workflowId, fence.workflowId),
eq(webhook.registrationStatus, 'candidate'),
eq(webhook.registrationGeneration, fence.generation),
isNull(webhook.preparedAt)
)
)
.limit(1)
if (unpreparedCandidates.length > 0) {
throw new Error('Webhook registration candidates are not fully prepared')
}
const newerRows = await tx
.select({ id: webhook.id })
.from(webhook)
.where(
and(
eq(webhook.workflowId, fence.workflowId),
inArray(webhook.registrationStatus, ['active', 'candidate']),
gt(webhook.registrationGeneration, fence.generation)
)
)
.limit(1)
if (newerRows.length > 0) throw new StaleWebhookRegistrationOperationError()
const now = new Date()
await tx
.update(webhook)
.set({
registrationStatus: 'retired',
isActive: false,
archivedAt: now,
updatedAt: now,
})
.where(
and(
eq(webhook.workflowId, fence.workflowId),
eq(webhook.registrationStatus, 'active'),
lt(webhook.registrationGeneration, fence.generation)
)
)
await tx
.update(webhook)
.set({
deploymentVersionId: fence.deploymentVersionId,
isActive: true,
archivedAt: null,
updatedAt: now,
})
.where(
and(
eq(webhook.workflowId, fence.workflowId),
eq(webhook.registrationStatus, 'active'),
eq(webhook.registrationGeneration, fence.generation)
)
)
await tx
.update(webhook)
.set({
registrationStatus: 'active',
deploymentVersionId: fence.deploymentVersionId,
isActive: true,
archivedAt: null,
updatedAt: now,
})
.where(
and(
eq(webhook.workflowId, fence.workflowId),
eq(webhook.registrationStatus, 'candidate'),
eq(webhook.registrationGeneration, fence.generation)
)
)
}
/** Lists retired rows only while the supplied activation is still the current generation. */
export async function listRetiredWebhookRegistrationsForCleanup(
input: WebhookRegistrationOperationFence & { limit?: number }
): Promise<WebhookRegistrationRow[]> {
return db.transaction(async (tx) => {
await assertCurrentOperation(tx, input, ['active'])
return tx
.select()
.from(webhook)
.where(
and(
eq(webhook.workflowId, input.workflowId),
eq(webhook.registrationStatus, 'retired'),
lt(webhook.registrationGeneration, input.generation)
)
)
.orderBy(webhook.registrationGeneration, webhook.id)
.limit(input.limit ?? 100)
})
}
/**
* Reloads an exact cleanup snapshot. A row advanced or reused by a newer generation is rejected.
*/
export async function getWebhookCleanupSnapshotIfCurrent(input: {
workflowId: string
webhookId: string
expectedGeneration: number
statuses: readonly WebhookRegistrationStatus[]
}): Promise<WebhookRegistrationRow | null> {
return db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(webhook)
.where(
and(
eq(webhook.workflowId, input.workflowId),
eq(webhook.id, input.webhookId),
eq(webhook.registrationGeneration, input.expectedGeneration),
inArray(webhook.registrationStatus, input.statuses)
)
)
.for('update')
return row ?? null
})
}
/** Deletes an externally cleaned row while preserving sticky path ownership. */
export async function deleteWebhookRegistrationAfterCleanup(input: {
workflowId: string
webhookId: string
expectedGeneration: number
statuses: readonly WebhookRegistrationStatus[]
}): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx
.delete(webhook)
.where(
and(
eq(webhook.workflowId, input.workflowId),
eq(webhook.id, input.webhookId),
eq(webhook.registrationGeneration, input.expectedGeneration),
inArray(webhook.registrationStatus, input.statuses)
)
)
.returning({ id: webhook.id })
return Boolean(deleted)
})
}
+109
View File
@@ -0,0 +1,109 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface Condition {
kind: string
column?: unknown
value?: unknown
conditions?: Condition[]
}
const { mockSelect } = vi.hoisted(() => ({ mockSelect: vi.fn() }))
vi.mock('@sim/db', () => ({ db: { select: mockSelect } }))
vi.mock('drizzle-orm', () => ({
and: (...conditions: Condition[]) => ({ kind: 'and', conditions }),
eq: (column: unknown, value: unknown) => ({ kind: 'eq', column, value }),
isNull: (column: unknown) => ({ kind: 'isNull', column }),
}))
import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server'
function claimLookupChain(rows: unknown[], captureCondition?: (condition: Condition) => void) {
return {
from: vi.fn(() => ({
where: vi.fn((condition: Condition) => {
captureCondition?.(condition)
return { limit: vi.fn().mockResolvedValue(rows) }
}),
})),
}
}
function liveRowsChain(rows: unknown[]) {
return {
from: vi.fn(() => ({
innerJoin: vi.fn(() => ({
where: vi.fn().mockResolvedValue(rows),
})),
})),
}
}
describe('findConflictingWebhookPathOwner', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the claim owner while the claim holder is mid-rotation', async () => {
let claimCondition: Condition | undefined
mockSelect.mockReturnValueOnce(
claimLookupChain([{ workflowId: 'workflow-owner' }], (condition) => {
claimCondition = condition
})
)
const owner = await findConflictingWebhookPathOwner({
path: ' /leads/ ',
workflowId: 'workflow-caller',
})
expect(owner).toBe('workflow-owner')
expect(mockSelect).toHaveBeenCalledTimes(1)
expect(claimCondition).toEqual(expect.objectContaining({ kind: 'eq', value: 'leads' }))
})
it('ignores the caller-owned claim and falls through to live rows', async () => {
mockSelect
.mockReturnValueOnce(claimLookupChain([{ workflowId: 'workflow-caller' }]))
.mockReturnValueOnce(liveRowsChain([]))
const owner = await findConflictingWebhookPathOwner({
path: 'leads',
workflowId: 'workflow-caller',
})
expect(owner).toBeNull()
expect(mockSelect).toHaveBeenCalledTimes(2)
})
it('returns a foreign live-row owner when no claim exists', async () => {
mockSelect
.mockReturnValueOnce(claimLookupChain([]))
.mockReturnValueOnce(
liveRowsChain([{ workflowId: 'workflow-caller' }, { workflowId: 'workflow-foreign' }])
)
const owner = await findConflictingWebhookPathOwner({
path: 'leads',
workflowId: 'workflow-caller',
})
expect(owner).toBe('workflow-foreign')
})
it('skips the claim lookup entirely for empty paths', async () => {
mockSelect.mockReturnValueOnce(liveRowsChain([]))
const owner = await findConflictingWebhookPathOwner({
path: ' ',
workflowId: 'workflow-caller',
})
expect(owner).toBeNull()
expect(mockSelect).toHaveBeenCalledTimes(1)
})
})
+26 -10
View File
@@ -1,19 +1,23 @@
import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { webhook, webhookPathClaim, workflow } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { normalizeWebhookRegistrationPath } from '@/lib/webhooks/registration-identity'
/**
* Returns the id of a different workflow that already owns an active webhook on
* the given path, or `null` if the path is free or owned by `workflowId`.
* Returns the id of a different workflow that already owns the given path, or
* `null` when the path is free or owned by `workflowId`.
*
* Webhook paths are user-controlled and the database only enforces uniqueness
* per deployment version, so this is the single guard against cross-tenant path
* collisions for every webhook creation path. The filter mirrors the runtime
* dispatcher (`findAllWebhooksForPath`): an active, non-archived webhook on a
* non-archived workflow inactive or archived webhooks never receive
* deliveries, so they must not reserve a path. All matching rows are scanned so
* a same-workflow row can never mask a foreign collision.
* Ownership has two sources, checked in order:
*
* 1. `webhook_path_claim` sticky ownership acquired by the stable
* registration protocol. Claims must be honored even while the owner's
* rows are non-deliverable candidates (mid-prepare) or mid-rotation,
* otherwise another workflow could take over the path in that window.
* 2. Live webhook rows mirrors the runtime dispatcher
* (`findAllWebhooksForPath`): an active, non-archived webhook on a
* non-archived workflow. All matching rows are scanned so a same-workflow
* row can never mask a foreign collision.
*/
export async function findConflictingWebhookPathOwner(params: {
path: string
@@ -23,6 +27,18 @@ export async function findConflictingWebhookPathOwner(params: {
const { path, workflowId, tx } = params
const dbCtx = tx ?? db
const normalizedPath = normalizeWebhookRegistrationPath(path)
if (normalizedPath) {
const [claim] = await dbCtx
.select({ workflowId: webhookPathClaim.workflowId })
.from(webhookPathClaim)
.where(eq(webhookPathClaim.path, normalizedPath))
.limit(1)
if (claim && claim.workflowId !== workflowId) {
return claim.workflowId
}
}
const existing = await dbCtx
.select({ workflowId: webhook.workflowId })
.from(webhook)
+23
View File
@@ -0,0 +1,23 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { isElseConditionTitle } from '@/lib/workflows/conditions'
describe('isElseConditionTitle', () => {
it.each(['else', 'Else', ' \t eLsE \n'])('recognizes "%s" as an else title', (title) => {
expect(isElseConditionTitle(title)).toBe(true)
})
it('rejects other condition titles', () => {
expect(isElseConditionTitle('else if')).toBe(false)
})
it('does not mutate legacy snapshot titles', () => {
const condition = { title: ' Else ' }
isElseConditionTitle(condition.title)
expect(condition.title).toBe(' Else ')
})
})
+9
View File
@@ -0,0 +1,9 @@
/**
* Returns whether a condition title represents the fallback branch.
*
* @param title - Condition title from a workflow snapshot
* @returns Whether the normalized title is `else`
*/
export function isElseConditionTitle(title: unknown): boolean {
return typeof title === 'string' && title.trim().toLowerCase() === 'else'
}
@@ -0,0 +1,62 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
canTransitionDeploymentOperation,
createDeploymentReadiness,
isDeploymentReadinessComplete,
parseDeploymentReadiness,
toSafeDeploymentError,
} from '@/lib/workflows/deployment-lifecycle'
describe('deployment lifecycle', () => {
it('allows only forward in-flight transitions', () => {
expect(canTransitionDeploymentOperation('preparing', 'activating')).toBe(true)
expect(canTransitionDeploymentOperation('preparing', 'failed')).toBe(true)
expect(canTransitionDeploymentOperation('activating', 'active')).toBe(true)
expect(canTransitionDeploymentOperation('active', 'activating')).toBe(false)
expect(canTransitionDeploymentOperation('failed', 'preparing')).toBe(false)
})
it('tracks required component readiness without accepting malformed state', () => {
const readiness = createDeploymentReadiness(
['webhooks', 'schedules'],
new Date('2026-07-14T08:00:00.000Z')
)
expect(isDeploymentReadinessComplete(readiness)).toBe(false)
readiness.webhooks.status = 'ready'
readiness.schedules.status = 'ready'
expect(isDeploymentReadinessComplete(readiness)).toBe(true)
expect(parseDeploymentReadiness(readiness)).toEqual(readiness)
expect(
parseDeploymentReadiness({ schedules: { status: 'unknown', updatedAt: 'now' } })
).toBeNull()
})
it('sanitizes persisted errors', () => {
const error = Object.assign(
new Error(
'authorization=Bearer-secret password=hunter2 https://user:pass@example.com failed\nnext'
),
{ code: 'UPSTREAM SECRET/FAILURE' }
)
expect(toSafeDeploymentError(error)).toEqual({
code: 'upstream_secret_failure',
message:
'authorization=[redacted] password=[redacted] https://[redacted]@example.com failed next',
})
})
it('drops driver bound-parameter tails that can carry credentials', () => {
const error = new Error(
'Failed query: insert into "webhook" ("id", "provider_config") values ($1, $2)\nparams: wh-1,{"triggerApiKey":"super-secret-key"}'
)
expect(toSafeDeploymentError(error).message).toBe(
'Failed query: insert into "webhook" ("id", "provider_config") values ($1, $2)'
)
})
})
@@ -0,0 +1,215 @@
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
export const DEPLOYMENT_OPERATION_PROTOCOL_VERSION = 2
export const DEPLOYMENT_OPERATION_STATUSES = [
'preparing',
'activating',
'active',
'failed',
'superseded',
] as const
export const DEPLOYMENT_OPERATION_ACTIONS = ['deploy', 'activate'] as const
export const DEPLOYMENT_COMPONENT_STATUSES = ['pending', 'ready'] as const
export type DeploymentOperationStatus = (typeof DEPLOYMENT_OPERATION_STATUSES)[number]
export type DeploymentOperationAction = (typeof DEPLOYMENT_OPERATION_ACTIONS)[number]
export type DeploymentComponentStatus = (typeof DEPLOYMENT_COMPONENT_STATUSES)[number]
export interface SafeDeploymentError {
code: string
message: string
}
export interface DeploymentComponentReadiness {
status: DeploymentComponentStatus
updatedAt: string
}
export type DeploymentReadiness = Record<string, DeploymentComponentReadiness>
const ALLOWED_TRANSITIONS: Readonly<
Record<DeploymentOperationStatus, DeploymentOperationStatus[]>
> = {
preparing: ['activating', 'failed', 'superseded'],
activating: ['active', 'failed', 'superseded'],
active: [],
failed: [],
superseded: [],
}
const MAX_ERROR_CODE_LENGTH = 64
const MAX_ERROR_MESSAGE_LENGTH = 500
/**
* Narrows a persisted value to a supported operation status.
*/
export function isDeploymentOperationStatus(value: unknown): value is DeploymentOperationStatus {
return DEPLOYMENT_OPERATION_STATUSES.includes(value as DeploymentOperationStatus)
}
/**
* Narrows a persisted value to a supported operation action.
*/
export function isDeploymentOperationAction(value: unknown): value is DeploymentOperationAction {
return DEPLOYMENT_OPERATION_ACTIONS.includes(value as DeploymentOperationAction)
}
/**
* Returns whether an operation may move between two lifecycle states.
*/
export function canTransitionDeploymentOperation(
from: DeploymentOperationStatus,
to: DeploymentOperationStatus
): boolean {
return ALLOWED_TRANSITIONS[from].includes(to)
}
/**
* Builds the initial readiness map for the components required by an attempt.
*/
export function createDeploymentReadiness(
components: readonly string[],
updatedAt = new Date()
): DeploymentReadiness {
const readiness: DeploymentReadiness = {}
for (const component of components) {
const normalizedComponent = component.trim()
if (!normalizedComponent) {
throw new Error('Deployment readiness component names cannot be empty')
}
if (readiness[normalizedComponent]) {
throw new Error(`Duplicate deployment readiness component: ${normalizedComponent}`)
}
readiness[normalizedComponent] = {
status: 'pending',
updatedAt: updatedAt.toISOString(),
}
}
return readiness
}
/**
* Returns true only when every declared component is ready.
*/
export function isDeploymentReadinessComplete(readiness: DeploymentReadiness): boolean {
return Object.values(readiness).every((component) => component.status === 'ready')
}
/**
* Narrows persisted JSON to the lifecycle readiness shape.
*/
export function parseDeploymentReadiness(value: unknown): DeploymentReadiness | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const readiness: DeploymentReadiness = {}
for (const [component, rawState] of Object.entries(value)) {
if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) return null
const state = rawState as Record<string, unknown>
if (
!DEPLOYMENT_COMPONENT_STATUSES.includes(state.status as DeploymentComponentStatus) ||
typeof state.updatedAt !== 'string'
) {
return null
}
readiness[component] = {
status: state.status as DeploymentComponentStatus,
updatedAt: state.updatedAt,
}
}
return readiness
}
export const DEPLOYMENT_ERROR_CODES = {
webhookPathConflict: 'webhook_path_conflict',
invalidTriggerConfiguration: 'invalid_trigger_configuration',
} as const
const NON_RETRYABLE_DEPLOYMENT_ERROR_CODES = new Set<string>([
DEPLOYMENT_ERROR_CODES.webhookPathConflict,
DEPLOYMENT_ERROR_CODES.invalidTriggerConfiguration,
])
/**
* Returns true when a persisted deployment error code cannot succeed on a
* plain retry the configuration must change first. Everything else
* (exhausted transient retries, generic failures) is worth redeploying as-is.
*/
export function isNonRetryableDeploymentErrorCode(code: string | null | undefined): boolean {
return code != null && NON_RETRYABLE_DEPLOYMENT_ERROR_CODES.has(code)
}
/**
* A preparation failure that cannot succeed on retry (path conflicts, invalid
* trigger configuration). The outbox handler fails the operation immediately
* instead of burning the full retry budget.
*/
export class NonRetryableDeploymentError extends Error {
constructor(
message: string,
readonly errorCode: string = 'preparation_failed'
) {
super(message)
this.name = 'NonRetryableDeploymentError'
}
}
/**
* Narrows an unknown caught value to {@link NonRetryableDeploymentError}.
*/
export function isNonRetryableDeploymentError(
error: unknown
): error is NonRetryableDeploymentError {
return error instanceof NonRetryableDeploymentError
}
/**
* Converts an unknown failure into the bounded public shape persisted on an attempt.
*/
export function toSafeDeploymentError(error: unknown, errorCode?: string): SafeDeploymentError {
const source =
errorCode ??
(error && typeof error === 'object' && 'code' in error
? String((error as { code?: unknown }).code ?? 'deployment_failed')
: 'deployment_failed')
const code =
truncate(
source
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, '_'),
MAX_ERROR_CODE_LENGTH,
''
) || 'deployment_failed'
const message = sanitizeDeploymentErrorMessage(
getErrorMessage(error, 'Deployment operation failed')
)
return { code, message }
}
function sanitizeDeploymentErrorMessage(message: string): string {
/**
* Driver errors ("Failed query: ...\nparams: ...") embed every bound
* parameter value, which can include user credentials from provider
* configs the parameter tail is dropped before anything else because the
* control-character pass below would otherwise fold it into one line.
*/
const withoutBoundParams = message.split(/\nparams: /)[0]
const withoutControlCharacters = withoutBoundParams.replace(/[\u0000-\u001F\u007F]+/g, ' ').trim()
const withoutUrlCredentials = withoutControlCharacters.replace(
/:\/\/[^/\s:@]+:[^/\s@]+@/g,
'://[redacted]@'
)
const withoutSecrets = withoutUrlCredentials.replace(
/\b(authorization|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|secret|password)\b(\s*[:=]\s*)([^\s,;]+)/gi,
'$1$2[redacted]'
)
return truncate(withoutSecrets || 'Deployment operation failed', MAX_ERROR_MESSAGE_LENGTH, '')
}
@@ -0,0 +1,514 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockLimit,
mockPrepareWebhooks,
mockGetDeploymentOperation,
mockMarkDeploymentComponentReadiness,
mockBeginDeploymentOperationActivation,
mockActivateDeploymentOperation,
mockMarkDeploymentOperationFailed,
mockRecordDeploymentOperationRetry,
mockIsDeploymentOperationCurrent,
mockIsDeploymentVersionProtectedByCurrentOperation,
mockCreateSchedulesForDeploy,
mockSyncMcpToolsForWorkflow,
mockNotifyMcpToolServers,
mockSetWorkflowMcpTransactionLockTimeout,
mockCleanupWebhooksForWorkflow,
mockActivateWebhookRegistrations,
mockCleanupRetiredWebhookRegistrations,
mockRecordAudit,
mockEmitWorkflowDeployedEvent,
mockCaptureServerEvent,
mockTx,
} = vi.hoisted(() => ({
mockLimit: vi.fn(),
mockPrepareWebhooks: vi.fn(),
mockGetDeploymentOperation: vi.fn(),
mockMarkDeploymentComponentReadiness: vi.fn(),
mockBeginDeploymentOperationActivation: vi.fn(),
mockActivateDeploymentOperation: vi.fn(),
mockMarkDeploymentOperationFailed: vi.fn(),
mockRecordDeploymentOperationRetry: vi.fn(),
mockIsDeploymentOperationCurrent: vi.fn(),
mockIsDeploymentVersionProtectedByCurrentOperation: vi.fn(),
mockCreateSchedulesForDeploy: vi.fn(),
mockSyncMcpToolsForWorkflow: vi.fn(),
mockNotifyMcpToolServers: vi.fn(),
mockSetWorkflowMcpTransactionLockTimeout: vi.fn(),
mockCleanupWebhooksForWorkflow: vi.fn(),
mockActivateWebhookRegistrations: vi.fn(),
mockCleanupRetiredWebhookRegistrations: vi.fn(),
mockRecordAudit: vi.fn(),
mockEmitWorkflowDeployedEvent: vi.fn(),
mockCaptureServerEvent: vi.fn(),
mockTx: { select: vi.fn(), update: vi.fn(), execute: vi.fn() },
}))
vi.mock('@sim/audit', () => ({
AuditAction: {
WORKFLOW_DEPLOYED: 'WORKFLOW_DEPLOYED',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED',
},
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
recordAudit: mockRecordAudit,
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: mockLimit,
})),
})),
})),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
},
workflow: {
id: 'workflow.id',
isDeployed: 'workflow.isDeployed',
},
workflowDeploymentVersion: {
id: 'workflowDeploymentVersion.id',
workflowId: 'workflowDeploymentVersion.workflowId',
state: 'workflowDeploymentVersion.state',
isActive: 'workflowDeploymentVersion.isActive',
},
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...args) => ({ type: 'and', args })),
eq: vi.fn((column, value) => ({ type: 'eq', column, value })),
ne: vi.fn((column, value) => ({ type: 'ne', column, value })),
}))
vi.mock('@/lib/core/config/env', () => ({
env: { INTERNAL_API_SECRET: 'secret' },
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: vi.fn(),
processOutboxEventById: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: () => 'request-generated',
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'http://localhost:3000',
getSocketServerUrl: () => 'http://localhost:3002',
}))
vi.mock('@/lib/mcp/server-locks', () => ({
setWorkflowMcpTransactionLockTimeout: mockSetWorkflowMcpTransactionLockTimeout,
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
notifyMcpToolServers: mockNotifyMcpToolServers,
removeMcpToolsForWorkflow: vi.fn(),
syncMcpToolsForWorkflow: mockSyncMcpToolsForWorkflow,
}))
vi.mock('@/lib/webhooks/deploy', () => ({
cleanupWebhooksForWorkflow: mockCleanupWebhooksForWorkflow,
prepareStableTriggerWebhooksForDeploy: vi.fn(),
saveTriggerWebhooksForDeploy: vi.fn(),
}))
vi.mock('@/lib/webhooks/registration-service', () => ({
cleanupRetiredWebhookRegistrationsAfterActivation: mockCleanupRetiredWebhookRegistrations,
}))
vi.mock('@/lib/webhooks/registration-store', () => ({
activateWebhookRegistrations: mockActivateWebhookRegistrations,
}))
vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({
activateDeploymentOperation: mockActivateDeploymentOperation,
beginDeploymentOperationActivation: mockBeginDeploymentOperationActivation,
getDeploymentOperation: mockGetDeploymentOperation,
isDeploymentOperationCurrent: mockIsDeploymentOperationCurrent,
isDeploymentVersionProtectedByCurrentOperation:
mockIsDeploymentVersionProtectedByCurrentOperation,
markDeploymentComponentReadiness: mockMarkDeploymentComponentReadiness,
markDeploymentOperationFailed: mockMarkDeploymentOperationFailed,
recordDeploymentOperationRetry: mockRecordDeploymentOperationRetry,
}))
vi.mock('@/lib/workflows/schedules', () => ({
createSchedulesForDeploy: mockCreateSchedulesForDeploy,
deleteSchedulesForWorkflow: vi.fn(),
}))
vi.mock('@/lib/workspace-events/emitter', () => ({
emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent,
}))
import type { OutboxEventContext } from '@/lib/core/outbox/service'
import { NonRetryableDeploymentError } from '@/lib/workflows/deployment-lifecycle'
import {
createWorkflowDeploymentOutboxHandlers,
type PrepareDeploymentV2Payload,
WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS,
} from '@/lib/workflows/deployment-outbox'
const NOW = new Date('2026-07-14T08:00:00.000Z')
function operation(overrides: Record<string, unknown> = {}) {
return {
id: 'operation-1',
workflowId: 'workflow-1',
deploymentVersionId: 'version-2',
version: 2,
previousActiveVersionId: 'version-1',
action: 'deploy' as const,
protocolVersion: 2,
generation: 2,
status: 'preparing' as const,
componentReadiness: {
webhooks: { status: 'pending', updatedAt: NOW.toISOString() },
schedules: { status: 'pending', updatedAt: NOW.toISOString() },
mcp: { status: 'pending', updatedAt: NOW.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-1',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
}
}
function payload(): PrepareDeploymentV2Payload {
return {
protocolVersion: 2,
operationId: 'operation-1',
generation: 2,
workflowId: 'workflow-1',
deploymentVersionId: 'version-2',
version: 2,
userId: 'user-1',
requestId: 'request-1',
checkpoints: {},
}
}
function context(controller = new AbortController(), attempts = 0): OutboxEventContext {
return {
eventId: 'event-1',
eventType: WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2,
attempts,
maxAttempts: 4,
signal: controller.signal,
checkpointPayload: vi.fn().mockResolvedValue(undefined),
}
}
function handler() {
return createWorkflowDeploymentOutboxHandlers({
prepareWebhooks: mockPrepareWebhooks,
})[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2]
}
describe('versioned deployment preparation outbox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockPrepareWebhooks.mockResolvedValue(undefined)
mockActivateWebhookRegistrations.mockResolvedValue(undefined)
mockCleanupRetiredWebhookRegistrations.mockResolvedValue(undefined)
mockCreateSchedulesForDeploy.mockResolvedValue({ success: true })
mockSyncMcpToolsForWorkflow.mockResolvedValue([{ serverId: 'mcp-server-1' }])
mockSetWorkflowMcpTransactionLockTimeout.mockResolvedValue(undefined)
mockEmitWorkflowDeployedEvent.mockResolvedValue(undefined)
mockMarkDeploymentOperationFailed.mockResolvedValue({
success: true,
operation: operation({ status: 'failed' }),
})
mockIsDeploymentOperationCurrent.mockResolvedValue(false)
mockIsDeploymentVersionProtectedByCurrentOperation.mockResolvedValue(false)
})
it('activates only after every preparation component is ready', async () => {
const preparing = operation()
const webhooksReady = operation({
componentReadiness: {
...preparing.componentReadiness,
webhooks: { status: 'ready', updatedAt: NOW.toISOString() },
},
})
const schedulesReady = operation({
componentReadiness: {
...webhooksReady.componentReadiness,
schedules: { status: 'ready', updatedAt: NOW.toISOString() },
},
})
const allReady = operation({
componentReadiness: {
...schedulesReady.componentReadiness,
mcp: { status: 'ready', updatedAt: NOW.toISOString() },
},
})
const activating = operation({
status: 'activating',
componentReadiness: allReady.componentReadiness,
})
const active = operation({
status: 'active',
componentReadiness: allReady.componentReadiness,
completedAt: NOW,
})
mockGetDeploymentOperation.mockResolvedValue(preparing)
mockLimit
.mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }])
.mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }])
mockMarkDeploymentComponentReadiness
.mockResolvedValueOnce({ success: true, operation: webhooksReady })
.mockResolvedValueOnce({ success: true, operation: schedulesReady })
.mockResolvedValueOnce({ success: true, operation: allReady })
mockBeginDeploymentOperationActivation.mockResolvedValue({
success: true,
operation: activating,
})
mockActivateDeploymentOperation.mockImplementation(async (input) => {
await input.onActivateTransaction?.(mockTx, active)
return { success: true, operation: active }
})
await handler()(payload(), context())
expect(mockPrepareWebhooks).toHaveBeenCalledTimes(1)
expect(mockCreateSchedulesForDeploy).toHaveBeenCalledWith(
'workflow-1',
{},
undefined,
'version-2',
'operation-1'
)
expect(
mockMarkDeploymentComponentReadiness.mock.calls.map(([input]) => input.component)
).toEqual(['webhooks', 'schedules', 'mcp'])
expect(mockSyncMcpToolsForWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
tx: mockTx,
notify: false,
state: { blocks: {} },
})
)
expect(mockActivateWebhookRegistrations).toHaveBeenCalledWith(mockTx, {
workflowId: 'workflow-1',
operationId: 'operation-1',
generation: 2,
deploymentVersionId: 'version-2',
})
expect(mockCleanupRetiredWebhookRegistrations).toHaveBeenCalledTimes(1)
expect(mockNotifyMcpToolServers).toHaveBeenCalledWith([{ serverId: 'mcp-server-1' }])
expect(mockRecordAudit).toHaveBeenCalledTimes(1)
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
'user-1',
'workflow_deployed',
{ workflow_id: 'workflow-1', workspace_id: 'workspace-1' },
expect.objectContaining({
groups: { workspace: 'workspace-1' },
setOnce: expect.objectContaining({ first_workflow_deployed_at: expect.any(String) }),
})
)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1)
expect(mockRecordAudit.mock.invocationCallOrder[0]).toBeGreaterThan(
mockActivateDeploymentOperation.mock.invocationCallOrder[0]
)
})
it('ignores a superseded generation without preparing side effects', async () => {
mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'superseded' }))
await handler()(payload(), context())
expect(mockPrepareWebhooks).not.toHaveBeenCalled()
expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled()
expect(mockMarkDeploymentComponentReadiness).not.toHaveBeenCalled()
expect(mockActivateDeploymentOperation).not.toHaveBeenCalled()
})
it('honors an aborted signal before starting any side effect', async () => {
const controller = new AbortController()
controller.abort()
await expect(handler()(payload(), context(controller))).rejects.toMatchObject({
name: 'AbortError',
})
expect(mockGetDeploymentOperation).not.toHaveBeenCalled()
expect(mockPrepareWebhooks).not.toHaveBeenCalled()
})
it('generation-guards failure on the final outbox attempt', async () => {
const preparing = operation()
mockGetDeploymentOperation.mockResolvedValue(preparing)
mockLimit
.mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }])
.mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }])
mockPrepareWebhooks.mockRejectedValue(new Error('provider unavailable'))
await expect(handler()(payload(), context(new AbortController(), 3))).rejects.toThrow(
'provider unavailable'
)
expect(mockMarkDeploymentOperationFailed).toHaveBeenCalledWith({
workflowId: 'workflow-1',
operationId: 'operation-1',
generation: 2,
error: expect.objectContaining({ message: 'provider unavailable' }),
errorCode: 'preparation_failed',
})
expect(mockActivateDeploymentOperation).not.toHaveBeenCalled()
})
it('retries transient mid-attempt failures without failing the operation', async () => {
const preparing = operation()
mockGetDeploymentOperation.mockResolvedValue(preparing)
mockLimit
.mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }])
.mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }])
mockPrepareWebhooks.mockRejectedValue(new Error('provider briefly unavailable'))
await expect(handler()(payload(), context(new AbortController(), 0))).rejects.toThrow(
'provider briefly unavailable'
)
expect(mockMarkDeploymentOperationFailed).not.toHaveBeenCalled()
expect(mockRecordDeploymentOperationRetry).toHaveBeenCalledWith({
workflowId: 'workflow-1',
operationId: 'operation-1',
generation: 2,
error: expect.objectContaining({ message: 'provider briefly unavailable' }),
})
expect(mockActivateDeploymentOperation).not.toHaveBeenCalled()
})
it('skips checkpointed webhook preparation on resume without re-running provider work', async () => {
const preparing = operation()
const webhooksReady = operation({
componentReadiness: {
...preparing.componentReadiness,
webhooks: { status: 'ready', updatedAt: NOW.toISOString() },
},
})
const schedulesReady = operation({
componentReadiness: {
...webhooksReady.componentReadiness,
schedules: { status: 'ready', updatedAt: NOW.toISOString() },
},
})
const allReady = operation({
componentReadiness: {
...schedulesReady.componentReadiness,
mcp: { status: 'ready', updatedAt: NOW.toISOString() },
},
})
mockGetDeploymentOperation.mockResolvedValue(preparing)
mockLimit
.mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }])
.mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }])
mockMarkDeploymentComponentReadiness
.mockResolvedValueOnce({ success: true, operation: webhooksReady })
.mockResolvedValueOnce({ success: true, operation: schedulesReady })
.mockResolvedValueOnce({ success: true, operation: allReady })
mockBeginDeploymentOperationActivation.mockResolvedValue({
success: true,
operation: operation({
status: 'activating',
componentReadiness: allReady.componentReadiness,
}),
})
mockActivateDeploymentOperation.mockResolvedValue({
success: true,
operation: operation({
status: 'active',
componentReadiness: allReady.componentReadiness,
completedAt: NOW,
}),
})
const resumedPayload = { ...payload(), checkpoints: { webhooksPrepared: true } }
await handler()(resumedPayload, context())
expect(mockPrepareWebhooks).not.toHaveBeenCalled()
expect(mockCreateSchedulesForDeploy).toHaveBeenCalledTimes(1)
expect(mockMarkDeploymentComponentReadiness.mock.calls[0][0]).toEqual(
expect.objectContaining({ component: 'webhooks', status: 'ready' })
)
})
it('fails the operation immediately on a non-retryable preparation error', async () => {
const preparing = operation()
mockGetDeploymentOperation.mockResolvedValue(preparing)
mockLimit
.mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }])
.mockResolvedValueOnce([{ id: 'version-2', state: { blocks: {} } }])
mockPrepareWebhooks.mockRejectedValue(
new NonRetryableDeploymentError(
'Webhook path "/leads" is already in use. Choose a different path.',
'webhook_path_conflict'
)
)
await expect(handler()(payload(), context())).resolves.toBeUndefined()
expect(mockMarkDeploymentOperationFailed).toHaveBeenCalledWith({
workflowId: 'workflow-1',
operationId: 'operation-1',
generation: 2,
error: expect.objectContaining({
message: 'Webhook path "/leads" is already in use. Choose a different path.',
}),
errorCode: 'webhook_path_conflict',
})
expect(mockActivateDeploymentOperation).not.toHaveBeenCalled()
})
it('keeps v1 cleanup from deleting a candidate owned by the current v2 operation', async () => {
mockLimit
.mockResolvedValueOnce([{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }])
.mockResolvedValueOnce([{ isActive: false }])
.mockResolvedValueOnce([{ isDeployed: true }])
mockIsDeploymentVersionProtectedByCurrentOperation.mockResolvedValue(true)
const cleanupHandler =
createWorkflowDeploymentOutboxHandlers()[
WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS
]
await cleanupHandler(
{
workflowId: 'workflow-1',
deploymentVersionIds: ['version-2'],
userId: 'user-1',
requestId: 'request-1',
},
context()
)
expect(mockCleanupWebhooksForWorkflow).not.toHaveBeenCalled()
expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled()
})
})
+737 -57
View File
@@ -1,33 +1,123 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, ne } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
import {
enqueueOutboxEvent,
type OutboxEventContext,
type OutboxHandler,
type OutboxHandlerRegistry,
type ProcessSingleOutboxResult,
processOutboxEventById,
} from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBaseUrl, getSocketServerUrl } from '@/lib/core/utils/urls'
import { setWorkflowMcpTransactionLockTimeout } from '@/lib/mcp/server-locks'
import {
notifyMcpToolServers,
removeMcpToolsForWorkflow,
syncMcpToolsForWorkflow,
} from '@/lib/mcp/workflow-mcp-sync'
import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { captureServerEvent } from '@/lib/posthog/server'
import {
cleanupWebhooksForWorkflow,
prepareStableTriggerWebhooksForDeploy,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { cleanupRetiredWebhookRegistrationsAfterActivation } from '@/lib/webhooks/registration-service'
import { activateWebhookRegistrations } from '@/lib/webhooks/registration-store'
import {
DEPLOYMENT_ERROR_CODES,
DEPLOYMENT_OPERATION_PROTOCOL_VERSION,
type DeploymentOperationStatus,
isDeploymentReadinessComplete,
isNonRetryableDeploymentError,
NonRetryableDeploymentError,
parseDeploymentReadiness,
} from '@/lib/workflows/deployment-lifecycle'
import {
activateDeploymentOperation,
beginDeploymentOperationActivation,
type DeploymentOperationGeneration,
getDeploymentOperation,
isDeploymentOperationCurrent,
isDeploymentVersionProtectedByCurrentOperation,
markDeploymentComponentReadiness,
markDeploymentOperationFailed,
recordDeploymentOperationRetry,
type WorkflowDeploymentOperation,
} from '@/lib/workflows/persistence/deployment-operations'
import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from '@/lib/workflows/schedules'
import { emitWorkflowDeployedEvent } from '@/lib/workspace-events/emitter'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDeploymentOutbox')
export const WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS = {
PREPARE_V2: 'workflow.deployment.prepare.v2',
/** One-release rolling compatibility for events admitted by pre-v2 pods. */
SYNC_ACTIVE_SIDE_EFFECTS: 'workflow.deployment.sync-active-side-effects',
/** One-release rolling compatibility for cleanup admitted by pre-v2 pods. */
CLEANUP_INACTIVE_SIDE_EFFECTS: 'workflow.deployment.cleanup-inactive-side-effects',
CLEANUP_UNDEPLOYED_SIDE_EFFECTS: 'workflow.deployment.cleanup-undeployed-side-effects',
} as const
export const DEPLOYMENT_READINESS_COMPONENTS = ['webhooks', 'schedules', 'mcp'] as const
/**
* One inline attempt at deploy time plus three exponential-backoff retries.
* Checkpoints make retries resumable, so a persistently failing preparation
* reaches its terminal failed state within roughly half a minute instead of
* burning a long retry tail while the UI shows retrying.
*/
const DEPLOYMENT_PREPARATION_MAX_ATTEMPTS = 4
interface DeploymentPreparationCheckpoints {
webhooksPrepared?: boolean
schedulesPrepared?: boolean
mcpReadyForActivation?: boolean
inactiveCleanupCompleted?: boolean
auditEmitted?: boolean
analyticsCaptured?: boolean
socketNotified?: boolean
workspaceEventEmitted?: boolean
}
interface DeploymentCleanupOperationFence extends DeploymentOperationGeneration {
deploymentVersionId: string
statuses: readonly DeploymentOperationStatus[]
}
export interface PrepareDeploymentV2Payload {
protocolVersion: number
operationId: string
generation: number
workflowId: string
deploymentVersionId: string
version: number
userId: string
requestId: string
checkpoints: DeploymentPreparationCheckpoints
}
export interface PrepareDeploymentWebhooksInput {
request: NextRequest
workflowId: string
workflow: Record<string, unknown>
userId: string
blocks: Record<string, BlockState>
requestId: string
deploymentVersionId: string
operationId: string
generation: number
signal: AbortSignal
}
export type PrepareDeploymentWebhooksHook = (input: PrepareDeploymentWebhooksInput) => Promise<void>
interface SyncActiveSideEffectsPayload {
workflowId: string
deploymentVersionId: string
@@ -50,16 +140,13 @@ interface CleanupInactiveSideEffectsPayload {
requestId?: string
}
export async function enqueueWorkflowDeploymentSideEffects(
export async function enqueueWorkflowDeploymentPreparation(
executor: Pick<typeof db, 'insert'>,
payload: SyncActiveSideEffectsPayload
payload: PrepareDeploymentV2Payload
): Promise<string> {
return enqueueOutboxEvent(
executor,
WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS,
payload,
{ maxAttempts: 10 }
)
return enqueueOutboxEvent(executor, WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2, payload, {
maxAttempts: DEPLOYMENT_PREPARATION_MAX_ATTEMPTS,
})
}
export async function enqueueWorkflowUndeploySideEffects(
@@ -92,6 +179,532 @@ export async function processWorkflowDeploymentOutboxEvent(
return processOutboxEventById(eventId, workflowDeploymentOutboxHandlers)
}
/**
* Notifies connected clients after deployment compatibility state changes.
*/
export async function notifySocketDeploymentChanged(
workflowId: string,
options: { signal?: AbortSignal; throwOnError?: boolean } = {}
): Promise<void> {
try {
const response = await fetch(`${getSocketServerUrl()}/api/workflow-deployed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.INTERNAL_API_SECRET,
},
body: JSON.stringify({ workflowId }),
signal: options.signal,
})
if (!response.ok) {
const error = new Error(
`Socket deployment notification failed (${response.status}) for workflow ${workflowId}`
)
if (options.throwOnError) throw error
logger.warn(error.message)
}
} catch (error) {
if (options.throwOnError) throw error
logger.error('Error sending workflow deployed event to socket server', error)
}
}
const defaultPrepareDeploymentWebhooks: PrepareDeploymentWebhooksHook = async (input) => {
input.signal.throwIfAborted()
const result = await prepareStableTriggerWebhooksForDeploy({
request: input.request,
workflowId: input.workflowId,
workflow: input.workflow,
userId: input.userId,
blocks: input.blocks,
requestId: input.requestId,
deploymentVersionId: input.deploymentVersionId,
operationId: input.operationId,
generation: input.generation,
signal: input.signal,
})
input.signal.throwIfAborted()
if (!result.success) {
const message = result.error?.message || 'Failed to prepare trigger configuration'
const status = result.error?.status ?? 500
if (status >= 400 && status < 500) {
throw new NonRetryableDeploymentError(
message,
status === 409
? DEPLOYMENT_ERROR_CODES.webhookPathConflict
: DEPLOYMENT_ERROR_CODES.invalidTriggerConfiguration
)
}
throw new Error(message)
}
}
function createPrepareDeploymentHandler(
prepareWebhooks: PrepareDeploymentWebhooksHook
): OutboxHandler {
return async (rawPayload, context) => {
const payload = parsePrepareDeploymentV2Payload(rawPayload)
try {
await prepareDeploymentOperation(payload, context, prepareWebhooks)
} catch (error) {
const isFinalAttempt = context.attempts + 1 >= context.maxAttempts
if (isNonRetryableDeploymentError(error) || isFinalAttempt) {
const operation = await getDeploymentOperation(payload)
if (operation?.status === 'preparing' || operation?.status === 'activating') {
await markDeploymentOperationFailed({
workflowId: payload.workflowId,
operationId: payload.operationId,
generation: payload.generation,
error,
errorCode: isNonRetryableDeploymentError(error)
? error.errorCode
: 'preparation_failed',
})
}
if (isNonRetryableDeploymentError(error)) {
logger.warn('Deployment preparation failed permanently; not retrying', {
workflowId: payload.workflowId,
operationId: payload.operationId,
error: error.message,
})
return
}
throw error
}
try {
/**
* Transient failure with retries remaining: surface the live error on
* the in-flight operation so status consumers show "retrying" instead
* of a blank pending state. Best-effort the outbox retry is the
* durable mechanism, not this record.
*/
await recordDeploymentOperationRetry({
workflowId: payload.workflowId,
operationId: payload.operationId,
generation: payload.generation,
error,
})
} catch (recordError) {
logger.warn('Failed to record deployment retry state', {
workflowId: payload.workflowId,
operationId: payload.operationId,
error: toError(recordError).message,
})
}
throw error
}
}
}
async function prepareDeploymentOperation(
payload: PrepareDeploymentV2Payload,
context: OutboxEventContext,
prepareWebhooks: PrepareDeploymentWebhooksHook
): Promise<void> {
context.signal.throwIfAborted()
let operation = await getDeploymentOperation(payload)
context.signal.throwIfAborted()
if (!operation || isTerminalNonActiveOperation(operation)) return
assertPreparationPayloadMatchesOperation(payload, operation)
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, payload.workflowId))
.limit(1)
context.signal.throwIfAborted()
if (!workflowRecord) throw new Error('Workflow missing during deployment preparation')
const checkpoints = { ...payload.checkpoints }
const checkpoint = async (patch: Partial<DeploymentPreparationCheckpoints>) => {
Object.assign(checkpoints, patch)
context.signal.throwIfAborted()
await context.checkpointPayload({ checkpoints })
context.signal.throwIfAborted()
}
if (operation.status === 'active') {
await cleanupRetiredWebhooksForOperation({
payload,
workflow: workflowRecord as Record<string, unknown>,
context,
})
await cleanupInactiveDeploymentsForOperation({
payload,
workflow: workflowRecord as Record<string, unknown>,
checkpoints,
checkpoint,
context,
})
await emitPostActivationSideEffects({
payload,
operation,
workflow: workflowRecord as Record<string, unknown>,
checkpoints,
checkpoint,
context,
})
return
}
if (operation.status !== 'preparing' && operation.status !== 'activating') return
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, payload.workflowId),
eq(workflowDeploymentVersion.id, payload.deploymentVersionId)
)
)
.limit(1)
context.signal.throwIfAborted()
if (!versionRow?.state) throw new Error('Deployment version missing during preparation')
const state = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = state.blocks
if (!blocks || typeof blocks !== 'object') {
throw new Error('Invalid deployed state structure')
}
operation = await prepareReadinessComponent({
payload,
operation,
component: 'webhooks',
checkpointKey: 'webhooksPrepared',
checkpoints,
checkpoint,
context,
prepare: async () => {
await prepareWebhooks({
request: new NextRequest(new URL('/api/webhooks', getBaseUrl())),
workflowId: payload.workflowId,
workflow: workflowRecord as Record<string, unknown>,
userId: payload.userId,
blocks,
requestId: payload.requestId,
deploymentVersionId: payload.deploymentVersionId,
operationId: payload.operationId,
generation: payload.generation,
signal: context.signal,
})
},
})
if (!operation) return
operation = await prepareReadinessComponent({
payload,
operation,
component: 'schedules',
checkpointKey: 'schedulesPrepared',
checkpoints,
checkpoint,
context,
prepare: async () => {
const result = await createSchedulesForDeploy(
payload.workflowId,
blocks,
undefined,
payload.deploymentVersionId,
payload.operationId
)
if (!result.success) {
throw new Error(result.error || 'Failed to prepare schedules')
}
},
})
if (!operation) return
operation = await prepareReadinessComponent({
payload,
operation,
component: 'mcp',
checkpointKey: 'mcpReadyForActivation',
checkpoints,
checkpoint,
context,
prepare: async () => {},
})
if (!operation) return
const readiness = parseDeploymentReadiness(operation.componentReadiness)
if (!readiness || !isDeploymentReadinessComplete(readiness)) return
if (operation.status === 'preparing') {
context.signal.throwIfAborted()
const activating = await beginDeploymentOperationActivation(payload)
context.signal.throwIfAborted()
if (!activating.success) {
if (activating.reason === 'stale_generation' || activating.reason === 'invalid_transition') {
return
}
throw new Error(activating.error)
}
operation = activating.operation
}
if (operation.status !== 'activating') return
let affectedMcpServers: Array<{ serverId: string }> = []
context.signal.throwIfAborted()
const activated = await activateDeploymentOperation({
workflowId: payload.workflowId,
operationId: payload.operationId,
generation: payload.generation,
onActivateTransaction: async (tx) => {
context.signal.throwIfAborted()
await activateWebhookRegistrations(tx, {
workflowId: payload.workflowId,
operationId: payload.operationId,
generation: payload.generation,
deploymentVersionId: payload.deploymentVersionId,
})
context.signal.throwIfAborted()
await setWorkflowMcpTransactionLockTimeout(tx)
context.signal.throwIfAborted()
affectedMcpServers = await syncMcpToolsForWorkflow({
workflowId: payload.workflowId,
requestId: payload.requestId,
state,
context: 'deployment-activation',
tx,
notify: false,
throwOnError: true,
})
context.signal.throwIfAborted()
},
})
context.signal.throwIfAborted()
if (!activated.success) {
if (activated.reason === 'stale_generation' || activated.reason === 'invalid_transition') return
throw new Error(activated.error)
}
operation = activated.operation
notifyMcpToolServers(affectedMcpServers)
context.signal.throwIfAborted()
await cleanupRetiredWebhooksForOperation({
payload,
workflow: workflowRecord as Record<string, unknown>,
context,
})
await cleanupInactiveDeploymentsForOperation({
payload,
workflow: workflowRecord as Record<string, unknown>,
checkpoints,
checkpoint,
context,
})
await emitPostActivationSideEffects({
payload,
operation,
workflow: workflowRecord as Record<string, unknown>,
checkpoints,
checkpoint,
context,
})
}
async function prepareReadinessComponent(params: {
payload: PrepareDeploymentV2Payload
operation: WorkflowDeploymentOperation
component: (typeof DEPLOYMENT_READINESS_COMPONENTS)[number]
checkpointKey: keyof DeploymentPreparationCheckpoints
checkpoints: DeploymentPreparationCheckpoints
checkpoint: (patch: Partial<DeploymentPreparationCheckpoints>) => Promise<void>
context: OutboxEventContext
prepare: () => Promise<void>
}): Promise<WorkflowDeploymentOperation | null> {
const readiness = parseDeploymentReadiness(params.operation.componentReadiness)
if (readiness?.[params.component]?.status === 'ready') {
if (!params.checkpoints[params.checkpointKey]) {
await params.checkpoint({ [params.checkpointKey]: true })
}
return params.operation
}
if (!params.checkpoints[params.checkpointKey]) {
params.context.signal.throwIfAborted()
await params.prepare()
params.context.signal.throwIfAborted()
await params.checkpoint({ [params.checkpointKey]: true })
}
params.context.signal.throwIfAborted()
const result = await markDeploymentComponentReadiness({
workflowId: params.payload.workflowId,
operationId: params.payload.operationId,
generation: params.payload.generation,
component: params.component,
status: 'ready',
expectedStatus: 'pending',
})
params.context.signal.throwIfAborted()
if (result.success) return result.operation
if (result.reason === 'stale_generation' || result.reason === 'invalid_transition') return null
throw new Error(result.error)
}
async function cleanupRetiredWebhooksForOperation(params: {
payload: PrepareDeploymentV2Payload
workflow: Record<string, unknown>
context: OutboxEventContext
}): Promise<void> {
params.context.signal.throwIfAborted()
await cleanupRetiredWebhookRegistrationsAfterActivation({
fence: {
workflowId: params.payload.workflowId,
operationId: params.payload.operationId,
generation: params.payload.generation,
deploymentVersionId: params.payload.deploymentVersionId,
},
workflow: params.workflow,
requestId: params.payload.requestId,
signal: params.context.signal,
})
}
async function cleanupInactiveDeploymentsForOperation(params: {
payload: PrepareDeploymentV2Payload
workflow: Record<string, unknown>
checkpoints: DeploymentPreparationCheckpoints
checkpoint: (patch: Partial<DeploymentPreparationCheckpoints>) => Promise<void>
context: OutboxEventContext
}): Promise<void> {
if (params.checkpoints.inactiveCleanupCompleted) return
const operationFence = {
workflowId: params.payload.workflowId,
operationId: params.payload.operationId,
generation: params.payload.generation,
deploymentVersionId: params.payload.deploymentVersionId,
statuses: ['active'] as const,
}
const shouldContinue = async () => {
params.context.signal.throwIfAborted()
const isCurrent = await isDeploymentOperationCurrent(operationFence)
params.context.signal.throwIfAborted()
return isCurrent
}
if (!(await shouldContinue())) return
await cleanupInactiveDeploymentVersions({
workflowId: params.payload.workflowId,
activeDeploymentVersionId: params.payload.deploymentVersionId,
workflow: params.workflow,
userId: params.payload.userId,
requestId: params.payload.requestId,
shouldContinue,
operationFence,
})
if (!(await shouldContinue())) return
await params.checkpoint({ inactiveCleanupCompleted: true })
}
async function emitPostActivationSideEffects(params: {
payload: PrepareDeploymentV2Payload
operation: WorkflowDeploymentOperation
workflow: Record<string, unknown>
checkpoints: DeploymentPreparationCheckpoints
checkpoint: (patch: Partial<DeploymentPreparationCheckpoints>) => Promise<void>
context: OutboxEventContext
}): Promise<void> {
if (!params.checkpoints.auditEmitted) {
params.context.signal.throwIfAborted()
const isVersionActivation = params.operation.action === 'activate'
recordAudit({
workspaceId: (params.workflow.workspaceId as string) || null,
actorId: params.operation.actorId,
action: isVersionActivation
? AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED
: AuditAction.WORKFLOW_DEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: params.payload.workflowId,
resourceName: (params.workflow.name as string) || undefined,
description: isVersionActivation
? `Activated deployment version ${params.payload.version}`
: `Deployed workflow "${(params.workflow.name as string) || params.payload.workflowId}"`,
metadata: {
deploymentVersionId: params.payload.deploymentVersionId,
version: params.payload.version,
previousVersionId: params.operation.previousActiveVersionId || undefined,
},
})
params.context.signal.throwIfAborted()
await params.checkpoint({ auditEmitted: true })
}
if (!params.checkpoints.analyticsCaptured) {
params.context.signal.throwIfAborted()
const workspaceId = (params.workflow.workspaceId as string) || ''
const isVersionActivation = params.operation.action === 'activate'
captureServerEvent(
params.payload.userId,
isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed',
{
workflow_id: params.payload.workflowId,
workspace_id: workspaceId,
...(isVersionActivation ? { version: params.payload.version } : {}),
},
{
groups: workspaceId ? { workspace: workspaceId } : undefined,
...(isVersionActivation
? {}
: { setOnce: { first_workflow_deployed_at: new Date().toISOString() } }),
}
)
await params.checkpoint({ analyticsCaptured: true })
}
if (!params.checkpoints.socketNotified) {
params.context.signal.throwIfAborted()
await notifySocketDeploymentChanged(params.payload.workflowId, {
signal: params.context.signal,
throwOnError: true,
})
params.context.signal.throwIfAborted()
await params.checkpoint({ socketNotified: true })
}
const workspaceId = params.workflow.workspaceId as string | null
if (workspaceId && !params.checkpoints.workspaceEventEmitted) {
params.context.signal.throwIfAborted()
await emitWorkflowDeployedEvent({
workflowId: params.payload.workflowId,
workflowName: (params.workflow.name as string) || params.payload.workflowId,
workspaceId,
version: params.payload.version,
})
params.context.signal.throwIfAborted()
await params.checkpoint({ workspaceEventEmitted: true })
}
}
function isTerminalNonActiveOperation(operation: WorkflowDeploymentOperation): boolean {
return operation.status === 'failed' || operation.status === 'superseded'
}
function assertPreparationPayloadMatchesOperation(
payload: PrepareDeploymentV2Payload,
operation: WorkflowDeploymentOperation
): void {
if (
payload.protocolVersion !== DEPLOYMENT_OPERATION_PROTOCOL_VERSION ||
operation.protocolVersion !== payload.protocolVersion
) {
throw new Error(`Unsupported deployment preparation protocol ${payload.protocolVersion}`)
}
if (
operation.deploymentVersionId !== payload.deploymentVersionId ||
operation.version !== payload.version
) {
throw new Error('Deployment preparation payload does not match its operation')
}
}
const syncActiveSideEffects = async (rawPayload: unknown): Promise<void> => {
const payload = parseSyncActiveSideEffectsPayload(rawPayload)
const requestId = payload.requestId ?? generateRequestId()
@@ -319,7 +932,10 @@ async function cleanupInactiveDeploymentVersions(params: {
workflow: Record<string, unknown>
userId: string
requestId: string
shouldContinue?: () => Promise<boolean>
operationFence?: DeploymentCleanupOperationFence
}): Promise<void> {
if (params.shouldContinue && !(await params.shouldContinue())) return
const inactiveVersions = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
@@ -332,12 +948,18 @@ async function cleanupInactiveDeploymentVersions(params: {
)
for (const version of inactiveVersions) {
if (params.shouldContinue && !(await params.shouldContinue())) return
if (await isDeploymentVersionProtectedByCurrentOperation(params.workflowId, version.id)) {
continue
}
await cleanupDeploymentVersionIfInactive({
workflowId: params.workflowId,
workflow: params.workflow,
userId: params.userId,
requestId: params.requestId,
deploymentVersionId: version.id,
shouldContinue: params.shouldContinue,
operationFence: params.operationFence,
})
}
}
@@ -348,20 +970,32 @@ async function cleanupDeploymentVersionIfInactive(params: {
workflow: Record<string, unknown>
userId: string
requestId: string
shouldContinue?: () => Promise<boolean>
operationFence?: DeploymentCleanupOperationFence
}): Promise<void> {
if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) {
await enqueueWorkflowDeploymentSideEffects(db, {
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
userId: params.userId,
requestId: params.requestId,
forceRecreateSubscriptions: true,
})
if (params.shouldContinue && !(await params.shouldContinue())) return
if (
await isDeploymentVersionProtectedByCurrentOperation(
params.workflowId,
params.deploymentVersionId
)
) {
return
}
if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) return
const isStillInactive = async () =>
!(await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId))
const isStillInactive = async () => {
if (params.shouldContinue && !(await params.shouldContinue())) return false
if (
await isDeploymentVersionProtectedByCurrentOperation(
params.workflowId,
params.deploymentVersionId
)
) {
return false
}
return !(await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId))
}
await cleanupWebhooksForWorkflow(
params.workflowId,
@@ -373,50 +1007,39 @@ async function cleanupDeploymentVersionIfInactive(params: {
isStillInactive
)
if (!(await isStillInactive())) {
await enqueueWorkflowDeploymentSideEffects(db, {
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
userId: params.userId,
requestId: params.requestId,
forceRecreateSubscriptions: true,
})
return
}
if (!(await isStillInactive())) return
const deletedSchedules = await deleteSchedulesForDeploymentIfInactive({
await deleteSchedulesForDeploymentIfInactive({
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
operationFence: params.operationFence,
})
if (!deletedSchedules) {
if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) {
await enqueueWorkflowDeploymentSideEffects(db, {
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
userId: params.userId,
requestId: params.requestId,
forceRecreateSubscriptions: true,
})
}
return
}
if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) {
await enqueueWorkflowDeploymentSideEffects(db, {
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
userId: params.userId,
requestId: params.requestId,
forceRecreateSubscriptions: true,
})
}
}
async function deleteSchedulesForDeploymentIfInactive(params: {
workflowId: string
deploymentVersionId: string
operationFence?: DeploymentCleanupOperationFence
}): Promise<boolean> {
return db.transaction(async (tx) => {
await tx
.select({ id: workflowTable.id })
.from(workflowTable)
.where(eq(workflowTable.id, params.workflowId))
.for('update')
if (params.operationFence && !(await isDeploymentOperationCurrent(params.operationFence, tx))) {
return false
}
if (
await isDeploymentVersionProtectedByCurrentOperation(
params.workflowId,
params.deploymentVersionId,
tx
)
) {
return false
}
const [versionRow] = await tx
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
@@ -678,6 +1301,46 @@ function parseSyncActiveSideEffectsPayload(payload: unknown): SyncActiveSideEffe
return { workflowId, deploymentVersionId, userId, requestId, forceRecreateSubscriptions }
}
function parsePrepareDeploymentV2Payload(payload: unknown): PrepareDeploymentV2Payload {
const record = parsePayloadRecord(payload)
const protocolVersion = parseRequiredPositiveInteger(record.protocolVersion, 'protocolVersion')
const operationId = parseRequiredString(record.operationId, 'operationId')
const generation = parseRequiredPositiveInteger(record.generation, 'generation')
const workflowId = parseRequiredString(record.workflowId, 'workflowId')
const deploymentVersionId = parseRequiredString(record.deploymentVersionId, 'deploymentVersionId')
const version = parseRequiredPositiveInteger(record.version, 'version')
const userId = parseRequiredString(record.userId, 'userId')
const requestId = parseRequiredString(record.requestId, 'requestId')
const checkpoints = parseDeploymentPreparationCheckpoints(record.checkpoints)
return {
protocolVersion,
operationId,
generation,
workflowId,
deploymentVersionId,
version,
userId,
requestId,
checkpoints,
}
}
function parseDeploymentPreparationCheckpoints(value: unknown): DeploymentPreparationCheckpoints {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const record = value as Record<string, unknown>
return {
...(record.webhooksPrepared === true ? { webhooksPrepared: true } : {}),
...(record.schedulesPrepared === true ? { schedulesPrepared: true } : {}),
...(record.mcpReadyForActivation === true ? { mcpReadyForActivation: true } : {}),
...(record.inactiveCleanupCompleted === true ? { inactiveCleanupCompleted: true } : {}),
...(record.auditEmitted === true ? { auditEmitted: true } : {}),
...(record.analyticsCaptured === true ? { analyticsCaptured: true } : {}),
...(record.socketNotified === true ? { socketNotified: true } : {}),
...(record.workspaceEventEmitted === true ? { workspaceEventEmitted: true } : {}),
}
}
function parseCleanupUndeployedSideEffectsPayload(
payload: unknown
): CleanupUndeployedSideEffectsPayload {
@@ -728,6 +1391,13 @@ function parseRequiredString(value: unknown, fieldName: string): string {
return value
}
function parseRequiredPositiveInteger(value: unknown, fieldName: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
throw new Error(`Deployment outbox payload is missing ${fieldName}`)
}
return value
}
function parseRequiredStringArray(value: unknown, fieldName: string): string[] {
if (
!Array.isArray(value) ||
@@ -738,8 +1408,18 @@ function parseRequiredStringArray(value: unknown, fieldName: string): string[] {
return value
}
export const workflowDeploymentOutboxHandlers: OutboxHandlerRegistry = {
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS]: syncActiveSideEffects,
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_INACTIVE_SIDE_EFFECTS]: cleanupInactiveSideEffects,
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS]: cleanupUndeployedSideEffects,
export function createWorkflowDeploymentOutboxHandlers(
options: { prepareWebhooks?: PrepareDeploymentWebhooksHook } = {}
): OutboxHandlerRegistry {
return {
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.PREPARE_V2]: createPrepareDeploymentHandler(
options.prepareWebhooks ?? defaultPrepareDeploymentWebhooks
),
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS]: syncActiveSideEffects,
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_INACTIVE_SIDE_EFFECTS]: cleanupInactiveSideEffects,
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS]:
cleanupUndeployedSideEffects,
}
}
export const workflowDeploymentOutboxHandlers = createWorkflowDeploymentOutboxHandlers()
@@ -0,0 +1,173 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockMaterializeExecutionData, mockSelect } = vi.hoisted(() => ({
mockMaterializeExecutionData: vi.fn(),
mockSelect: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: { select: mockSelect },
}))
vi.mock('@/lib/logs/execution/trace-store', () => ({
materializeExecutionData: mockMaterializeExecutionData,
TRACE_STORE_REF_KEY: 'traceStoreRef',
}))
import {
getExecutionInputForWorkflow,
getExecutionStateForWorkflow,
getLatestExecutionStateWithExecutionId,
} from '@/lib/workflows/executor/execution-state'
const EXECUTION_STATE = {
blockStates: {},
executedBlocks: ['block-1'],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
}
function createSelectChain(rows: unknown[]) {
const chain = {
from: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn().mockResolvedValue(rows),
}
chain.from.mockReturnValue(chain)
chain.where.mockReturnValue(chain)
chain.orderBy.mockReturnValue(chain)
return chain
}
describe('execution state lookup', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMaterializeExecutionData.mockReset()
mockSelect.mockReset()
})
it('materializes externalized execution data for a specific execution', async () => {
const slimExecutionData = {
traceStoreRef: {
__simLargeValueRef: true,
id: 'value-1',
key: 'execution/workspace-1/workflow-1/execution-1/value.json',
kind: 'object',
size: 100,
version: 1,
executionId: 'execution-1',
},
}
mockSelect.mockReturnValueOnce(
createSelectChain([
{
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionData: slimExecutionData,
},
])
)
mockMaterializeExecutionData.mockResolvedValueOnce({
executionState: EXECUTION_STATE,
})
const result = await getExecutionStateForWorkflow('execution-1', 'workflow-1')
expect(mockMaterializeExecutionData).toHaveBeenCalledWith(slimExecutionData, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
expect(result).toEqual(EXECUTION_STATE)
})
it('materializes externalized execution data when reusing workflow input', async () => {
const slimExecutionData = {
traceStoreRef: {
__simLargeValueRef: true,
id: 'value-1',
key: 'execution/workspace-1/workflow-1/execution-1/value.json',
kind: 'object',
size: 100,
version: 1,
executionId: 'execution-1',
},
}
mockSelect.mockReturnValueOnce(
createSelectChain([
{
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionData: slimExecutionData,
},
])
)
mockMaterializeExecutionData.mockResolvedValueOnce({
workflowInput: { leadId: 'lead-1' },
})
const result = await getExecutionInputForWorkflow('execution-1', 'workflow-1')
expect(result).toEqual({
found: true,
input: { leadId: 'lead-1' },
})
expect(mockMaterializeExecutionData).toHaveBeenCalledWith(slimExecutionData, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
})
it('checks older pointer-backed candidates when the latest has no execution state', async () => {
mockSelect.mockReturnValueOnce(
createSelectChain([
{
executionId: 'execution-2',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionState: null,
traceStoreRef: { id: 'value-2' },
},
{
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
executionState: null,
traceStoreRef: { id: 'value-1' },
},
])
)
mockMaterializeExecutionData
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ executionState: EXECUTION_STATE })
const result = await getLatestExecutionStateWithExecutionId('workflow-1')
expect(result).toEqual({
executionId: 'execution-1',
state: EXECUTION_STATE,
})
expect(mockMaterializeExecutionData).toHaveBeenCalledTimes(2)
expect(mockMaterializeExecutionData).toHaveBeenNthCalledWith(
1,
{
executionState: null,
traceStoreRef: { id: 'value-2' },
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
}
)
})
})
@@ -1,9 +1,12 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import { and, desc, eq, or, sql } from 'drizzle-orm'
import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store'
import type { SerializableExecutionState } from '@/executor/execution/types'
export interface ExecutionStateRecord {
const LATEST_EXECUTION_STATE_CANDIDATE_LIMIT = 10
interface ExecutionStateRecord {
executionId: string
state: SerializableExecutionState
}
@@ -27,16 +30,30 @@ function extractExecutionState(executionData: unknown): SerializableExecutionSta
return isSerializableExecutionState(state) ? state : null
}
export async function getExecutionState(
interface ExecutionStateRow {
executionId: string
): Promise<SerializableExecutionState | null> {
const [row] = await db
.select({ executionData: workflowExecutionLogs.executionData })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
workflowId: string | null
workspaceId: string
executionData: unknown
}
return extractExecutionState(row?.executionData)
async function materializeExecutionDataFromRow(
row: ExecutionStateRow | undefined
): Promise<Record<string, unknown> | null> {
if (!row) return null
return materializeExecutionData(row.executionData as Record<string, unknown> | null, {
workspaceId: row.workspaceId,
workflowId: row.workflowId,
executionId: row.executionId,
})
}
async function extractExecutionStateFromRow(
row: ExecutionStateRow | undefined
): Promise<SerializableExecutionState | null> {
const executionData = await materializeExecutionDataFromRow(row)
return extractExecutionState(executionData)
}
export async function getExecutionStateForWorkflow(
@@ -44,7 +61,12 @@ export async function getExecutionStateForWorkflow(
workflowId: string
): Promise<SerializableExecutionState | null> {
const [row] = await db
.select({ executionData: workflowExecutionLogs.executionData })
.select({
executionId: workflowExecutionLogs.executionId,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
executionData: workflowExecutionLogs.executionData,
})
.from(workflowExecutionLogs)
.where(
and(
@@ -54,7 +76,7 @@ export async function getExecutionStateForWorkflow(
)
.limit(1)
return extractExecutionState(row?.executionData)
return extractExecutionStateFromRow(row)
}
/**
@@ -67,7 +89,12 @@ export async function getExecutionInputForWorkflow(
workflowId: string
): Promise<{ found: boolean; input?: unknown }> {
const [row] = await db
.select({ executionData: workflowExecutionLogs.executionData })
.select({
executionId: workflowExecutionLogs.executionId,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
executionData: workflowExecutionLogs.executionData,
})
.from(workflowExecutionLogs)
.where(
and(
@@ -81,35 +108,46 @@ export async function getExecutionInputForWorkflow(
return { found: false }
}
const data = row.executionData as { workflowInput?: unknown } | null | undefined
const data = await materializeExecutionDataFromRow(row)
return { found: true, input: data?.workflowInput }
}
export async function getLatestExecutionState(
workflowId: string
): Promise<SerializableExecutionState | null> {
const record = await getLatestExecutionStateWithExecutionId(workflowId)
return record?.state ?? null
}
export async function getLatestExecutionStateWithExecutionId(
workflowId: string
): Promise<ExecutionStateRecord | null> {
const [row] = await db
const rows = await db
.select({
executionId: workflowExecutionLogs.executionId,
executionData: workflowExecutionLogs.executionData,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
executionState: sql<unknown>`${workflowExecutionLogs.executionData} -> 'executionState'`,
traceStoreRef: sql<unknown>`${workflowExecutionLogs.executionData} -> ${TRACE_STORE_REF_KEY}`,
})
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.workflowId, workflowId),
sql`${workflowExecutionLogs.executionData} -> 'executionState' IS NOT NULL`
or(
sql`${workflowExecutionLogs.executionData} -> 'executionState' IS NOT NULL`,
sql`${workflowExecutionLogs.executionData} -> ${TRACE_STORE_REF_KEY} IS NOT NULL`
)
)
)
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(1)
.limit(LATEST_EXECUTION_STATE_CANDIDATE_LIMIT)
const state = extractExecutionState(row?.executionData)
return row && state ? { executionId: row.executionId, state } : null
for (const row of rows) {
const state = await extractExecutionStateFromRow({
executionId: row.executionId,
workflowId: row.workflowId,
workspaceId: row.workspaceId,
executionData: {
executionState: row.executionState,
[TRACE_STORE_REF_KEY]: row.traceStoreRef,
},
})
if (state) return { executionId: row.executionId, state }
}
return null
}
+10 -1
View File
@@ -26,6 +26,9 @@ vi.mock('@sim/db', () => ({
select: mockSelect,
transaction: mockTransaction,
},
workflow: { id: 'id' },
workflowDeploymentOperation: { workflowId: 'workflowId', status: 'status' },
workflowDeploymentVersion: { workflowId: 'workflowId', isActive: 'isActive' },
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
@@ -94,6 +97,9 @@ describe('workflow lifecycle', () => {
const tx = {
update: vi.fn().mockImplementation(() => createUpdateChain()),
delete: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
}
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
callback(tx)
@@ -102,7 +108,10 @@ describe('workflow lifecycle', () => {
const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' })
expect(result.archived).toBe(true)
expect(tx.update).toHaveBeenCalledTimes(6)
expect(tx.update).toHaveBeenCalledTimes(7)
const supersedeSet = tx.update.mock.results[0]?.value.set
expect(supersedeSet).toHaveBeenCalledWith(expect.objectContaining({ status: 'superseded' }))
expect(tx.delete).toHaveBeenCalledTimes(1)
expect(mockWorkflowDeleted).toHaveBeenCalledWith({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
+5
View File
@@ -18,6 +18,8 @@ import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims'
import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations'
import { getWorkflowById } from '@/lib/workflows/utils'
const logger = createLogger('WorkflowLifecycle')
@@ -106,6 +108,9 @@ export async function archiveWorkflow(
.where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt)))
await db.transaction(async (tx) => {
await supersedeInFlightDeploymentOperations(tx, workflowId)
await releaseWebhookPathClaims(tx, workflowId)
await tx
.update(workflowSchedule)
.set({
@@ -6,7 +6,11 @@ import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { performFullDeploy } from '@/lib/workflows/orchestration/deploy'
import {
getWorkflowDeploymentSummary,
performFullDeploy,
} from '@/lib/workflows/orchestration/deploy'
import { checkNeedsRedeployment } from '@/app/api/workflows/utils'
const logger = createLogger('ChatDeployOrchestration')
@@ -64,14 +68,43 @@ export async function performChatDeploy(
...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}),
}
const deployResult = await performFullDeploy({
workflowId,
userId,
versionDescription: params.versionDescription,
versionName: params.versionName,
})
if (!deployResult.success) {
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
/**
* Only deploy when the draft drifted from the active version, and never
* while another attempt is in flight a blocked retry must not admit a
* fresh deployment version on top of the pending one.
*/
const deploymentSummary = await getWorkflowDeploymentSummary(workflowId)
const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status
if (attemptStatus === 'preparing' || attemptStatus === 'activating') {
return {
success: false,
error:
'A workflow deployment is still preparing. Retry chat deployment after it becomes active.',
}
}
const needsRedeploy =
!deploymentSummary.activeDeployment || (await checkNeedsRedeployment(workflowId))
let deployResult: Awaited<ReturnType<typeof performFullDeploy>> | null = null
if (needsRedeploy) {
deployResult = await performFullDeploy({
workflowId,
userId,
versionDescription: params.versionDescription,
versionName: params.versionName,
})
if (!deployResult.success) {
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
}
if (deployResult.latestDeploymentAttempt?.status !== 'active') {
return {
success: false,
error:
deployResult.warnings?.[0] ??
'Workflow deployment is still preparing. Retry chat deployment after it becomes active.',
}
}
}
let encryptedPassword: string | null = null
@@ -185,11 +218,17 @@ export async function performChatDeploy(
success: true,
chatId,
chatUrl,
deployedAt: deployResult.deployedAt,
version: deployResult.version,
deployedAt: deployResult?.deployedAt ?? toDeployedAtDate(deploymentSummary),
version: deployResult?.version ?? deploymentSummary.activeDeployment?.version,
}
}
function toDeployedAtDate(summary: {
activeDeployment: { deployedAt: string } | null
}): Date | null {
return summary.activeDeployment ? new Date(summary.activeDeployment.deployedAt) : null
}
export interface PerformChatUndeployParams {
chatId: string
userId: string
@@ -10,11 +10,16 @@ const {
mockRecordAudit,
mockCaptureServerEvent,
mockTransaction,
mockDeployWorkflow,
mockActivateWorkflowVersion,
mockValidateWorkflowSchedules,
mockValidateTriggerWebhookConfigForDeploy,
mockEmitWorkflowDeployedEvent,
mockPrepareWorkflowDeployment,
mockPrepareWorkflowVersionActivation,
mockGetWorkflowDeploymentStatus,
mockEnqueueWorkflowDeploymentPreparation,
mockProcessWorkflowDeploymentOutboxEvent,
mockNotifySocketDeploymentChanged,
mockLoadWorkflowDeploymentSnapshot,
mockTx,
} = vi.hoisted(() => ({
mockLimit: vi.fn(),
@@ -23,11 +28,16 @@ const {
mockRecordAudit: vi.fn(),
mockCaptureServerEvent: vi.fn(),
mockTransaction: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockActivateWorkflowVersion: vi.fn(),
mockValidateWorkflowSchedules: vi.fn(),
mockValidateTriggerWebhookConfigForDeploy: vi.fn(),
mockEmitWorkflowDeployedEvent: vi.fn(),
mockPrepareWorkflowDeployment: vi.fn(),
mockPrepareWorkflowVersionActivation: vi.fn(),
mockGetWorkflowDeploymentStatus: vi.fn(),
mockEnqueueWorkflowDeploymentPreparation: vi.fn(),
mockProcessWorkflowDeploymentOutboxEvent: vi.fn(),
mockNotifySocketDeploymentChanged: vi.fn(),
mockLoadWorkflowDeploymentSnapshot: vi.fn(),
mockTx: {
select: vi.fn(() => ({
from: vi.fn(() => ({
@@ -59,7 +69,11 @@ vi.mock('@sim/db', () => ({
})),
transaction: mockTransaction,
},
workflow: { id: 'workflow.id' },
workflow: {
id: 'workflow.id',
deployedAt: 'workflow.deployedAt',
workspaceId: 'workflow.workspaceId',
},
workflowDeploymentVersion: {
workflowId: 'workflowDeploymentVersion.workflowId',
version: 'workflowDeploymentVersion.version',
@@ -80,13 +94,22 @@ vi.mock('@sim/audit', () => ({
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowDeploymentSideEffects: vi.fn().mockResolvedValue('outbox-1'),
enqueueWorkflowDeploymentPreparation: mockEnqueueWorkflowDeploymentPreparation,
enqueueWorkflowUndeploySideEffects: vi.fn().mockResolvedValue('outbox-2'),
processWorkflowDeploymentOutboxEvent: vi.fn().mockResolvedValue('completed'),
notifySocketDeploymentChanged: mockNotifySocketDeploymentChanged,
processWorkflowDeploymentOutboxEvent: mockProcessWorkflowDeploymentOutboxEvent,
DEPLOYMENT_READINESS_COMPONENTS: ['webhooks', 'schedules', 'mcp'],
}))
vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({
getWorkflowDeploymentStatus: mockGetWorkflowDeploymentStatus,
prepareWorkflowDeployment: mockPrepareWorkflowDeployment,
prepareWorkflowVersionActivation: mockPrepareWorkflowVersionActivation,
}))
vi.mock('@/lib/workspace-events/emitter', () => ({
emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent,
emitWorkflowUndeployedEvent: vi.fn(),
}))
vi.mock('@/lib/core/config/env', () => ({
@@ -103,29 +126,16 @@ vi.mock('@/lib/posthog/server', () => ({
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
activateWorkflowVersion: mockActivateWorkflowVersion,
activateWorkflowVersionById: vi.fn(),
deployWorkflow: mockDeployWorkflow,
loadWorkflowDeploymentSnapshot: vi.fn(),
loadWorkflowDeploymentSnapshot: mockLoadWorkflowDeploymentSnapshot,
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
undeployWorkflow: vi.fn(),
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
removeMcpToolsForWorkflow: vi.fn(),
syncMcpToolsForWorkflow: vi.fn(),
}))
vi.mock('@/lib/webhooks/deploy', () => ({
cleanupWebhooksForWorkflow: vi.fn(),
restorePreviousVersionWebhooks: vi.fn(),
saveTriggerWebhooksForDeploy: vi.fn(),
validateTriggerWebhookConfigForDeploy: mockValidateTriggerWebhookConfigForDeploy,
}))
vi.mock('@/lib/workflows/schedules', () => ({
cleanupDeploymentVersion: vi.fn(),
createSchedulesForDeploy: vi.fn(),
validateWorkflowSchedules: mockValidateWorkflowSchedules,
}))
@@ -244,37 +254,226 @@ describe('performFullDeploy workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-default',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-1',
version: 4,
previousActiveVersionId: null,
action: 'deploy',
protocolVersion: 2,
generation: 1,
status: 'active',
componentReadiness: {
webhooks: { status: 'ready', updatedAt: now.toISOString() },
schedules: { status: 'ready', updatedAt: now.toISOString() },
mcp: { status: 'ready', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-default',
requestHash: 'hash',
actorId: 'user-1',
completedAt: now,
createdAt: now,
updatedAt: now,
}
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed')
mockNotifySocketDeploymentChanged.mockResolvedValue(undefined)
mockLimit.mockResolvedValue([
{ id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
])
mockDeployWorkflow.mockResolvedValue({
success: true,
deployedAt: new Date(),
version: 4,
deploymentVersionId: 'dv-1',
previousVersionId: null,
currentState: { blocks: {} },
mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {},
lastSaved: now.getTime(),
})
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-default')
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 4,
deployedAt: now,
},
latestOperation: operation,
})
})
it('emits workflow_deployed after a successful deploy', async () => {
it('always admits deploys through v2 without legacy immediate activation', async () => {
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({
workflowId: 'workflow-1',
workflowName: 'My Workflow',
workspaceId: 'workspace-1',
version: 4,
})
expect(mockPrepareWorkflowDeployment).toHaveBeenCalledTimes(1)
expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledWith(
mockTx,
expect.objectContaining({ protocolVersion: 2 })
)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('does not emit when the deploy fails', async () => {
mockDeployWorkflow.mockResolvedValueOnce({ success: false, error: 'nope' })
it('keeps a first deploy pending without claiming an active deployment', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-1',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-candidate',
version: 1,
previousActiveVersionId: null,
action: 'deploy',
protocolVersion: 2,
generation: 1,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'pending', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-1',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {},
lastSaved: now.getTime(),
})
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-1')
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: null,
latestOperation: operation,
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
requestId: 'request-1',
})
expect(result).toMatchObject({
success: true,
activeDeployment: null,
latestDeploymentAttempt: {
id: 'operation-1',
status: 'preparing',
deploymentVersionId: 'dv-candidate',
},
warnings: [expect.stringContaining('workflow remains undeployed')],
})
expect(result.deployedAt).toBeUndefined()
})
it('preserves the old active deployment while a redeploy prepares', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-2',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-candidate',
version: 5,
previousActiveVersionId: 'dv-live',
action: 'deploy',
protocolVersion: 2,
generation: 2,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'ready', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-2',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {},
lastSaved: now.getTime(),
})
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-2')
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-live',
version: 4,
deployedAt: now,
},
latestOperation: operation,
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
requestId: 'request-2',
})
expect(result).toMatchObject({
success: true,
/**
* Top-level version identifies the snapshot this call admitted, while
* activeDeployment keeps reporting what is actually live during the
* pending cutover.
*/
deploymentVersionId: 'dv-candidate',
version: 5,
activeDeployment: {
deploymentVersionId: 'dv-live',
version: 4,
},
latestDeploymentAttempt: {
deploymentVersionId: 'dv-candidate',
status: 'preparing',
},
})
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('surfaces v2 admission failure without falling back to legacy activation', async () => {
mockPrepareWorkflowDeployment.mockResolvedValueOnce({
success: false,
reason: 'invalid_request',
error: 'nope',
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
@@ -282,18 +481,63 @@ describe('performFullDeploy workspace event emission', () => {
})
expect(result.success).toBe(false)
expect(result.error).toBe('nope')
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('emission rejection does not fail the deploy', async () => {
mockEmitWorkflowDeployedEvent.mockRejectedValueOnce(new Error('emit failed'))
it('returns a failure response when this request attempt fails terminally inline', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-conflict',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-candidate',
version: 5,
previousActiveVersionId: null,
action: 'deploy',
protocolVersion: 2,
generation: 2,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'pending', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-conflict',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: null,
latestOperation: {
...operation,
status: 'failed',
errorCode: 'webhook_path_conflict',
errorMessage: 'Webhook path "/leads" is already in use. Choose a different path.',
completedAt: now,
},
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
requestId: 'request-conflict',
})
expect(result.success).toBe(true)
expect(result).toMatchObject({
success: false,
error: 'Webhook path "/leads" is already in use. Choose a different path.',
errorCode: 'conflict',
})
})
})
@@ -301,31 +545,129 @@ describe('performActivateVersion workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-activate-default',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-2',
version: 2,
previousActiveVersionId: 'dv-1',
action: 'activate',
protocolVersion: 2,
generation: 4,
status: 'active',
componentReadiness: {
webhooks: { status: 'ready', updatedAt: now.toISOString() },
schedules: { status: 'ready', updatedAt: now.toISOString() },
mcp: { status: 'ready', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-activate-default',
requestHash: 'hash',
actorId: 'user-1',
completedAt: now,
createdAt: now,
updatedAt: now,
}
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed')
mockNotifySocketDeploymentChanged.mockResolvedValue(undefined)
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockLimit.mockResolvedValue([{ id: 'dv-2', state: { blocks: {} }, isActive: false }])
mockActivateWorkflowVersion.mockResolvedValue({
success: true,
deployedAt: new Date(),
previousVersionId: 'dv-1',
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate-default')
mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-2',
version: 2,
deployedAt: now,
},
latestOperation: operation,
})
})
it('emits workflow_deployed when activating a version (rollback/activation)', async () => {
it('always admits version activation through v2 without legacy activation', async () => {
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({
expect(mockPrepareWorkflowVersionActivation).toHaveBeenCalledTimes(1)
expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledWith(
mockTx,
expect.objectContaining({ protocolVersion: 2 })
)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('keeps the current version active while version activation prepares', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-activate',
workflowId: 'workflow-1',
workflowName: 'My Workflow',
workspaceId: 'workspace-1',
deploymentVersionId: 'dv-2',
version: 2,
previousActiveVersionId: 'dv-1',
action: 'activate',
protocolVersion: 2,
generation: 4,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'pending', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-activate',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate')
mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 1,
deployedAt: now,
},
latestOperation: operation,
})
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
requestId: 'request-activate',
})
expect(result).toMatchObject({
success: true,
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 1,
},
latestDeploymentAttempt: {
id: 'operation-activate',
deploymentVersionId: 'dv-2',
status: 'preparing',
},
warnings: [expect.stringContaining('prior workflow version remains active')],
})
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('does not emit when the version is already active (no-op activation)', async () => {
@@ -337,24 +679,27 @@ describe('performActivateVersion workspace event emission', () => {
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('does not emit when activation fails', async () => {
mockActivateWorkflowVersion.mockResolvedValueOnce({ success: false, error: 'nope' })
it('surfaces v2 activation admission failure without legacy fallback', async () => {
mockPrepareWorkflowVersionActivation.mockResolvedValueOnce({
success: false,
reason: 'invalid_request',
error: 'nope',
})
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(false)
expect(result.error).toBe('nope')
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
})
+418 -161
View File
@@ -2,6 +2,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
@@ -10,54 +12,70 @@ import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { captureServerEvent } from '@/lib/posthog/server'
import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy'
import {
enqueueWorkflowDeploymentSideEffects,
DEPLOYMENT_ERROR_CODES,
type DeploymentComponentStatus,
isDeploymentOperationAction,
isDeploymentOperationStatus,
isNonRetryableDeploymentErrorCode,
parseDeploymentReadiness,
} from '@/lib/workflows/deployment-lifecycle'
import {
DEPLOYMENT_READINESS_COMPONENTS,
enqueueWorkflowDeploymentPreparation,
enqueueWorkflowUndeploySideEffects,
notifySocketDeploymentChanged,
processWorkflowDeploymentOutboxEvent,
} from '@/lib/workflows/deployment-outbox'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
import {
activateWorkflowVersion,
deployWorkflow,
getWorkflowDeploymentStatus,
prepareWorkflowDeployment,
prepareWorkflowVersionActivation,
type WorkflowDeploymentOperation,
type WorkflowDeploymentStatus,
} from '@/lib/workflows/persistence/deployment-operations'
import {
loadWorkflowDeploymentSnapshot,
saveWorkflowToNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { validateWorkflowSchedules } from '@/lib/workflows/schedules'
import {
emitWorkflowDeployedEvent,
emitWorkflowUndeployedEvent,
} from '@/lib/workspace-events/emitter'
import { emitWorkflowUndeployedEvent } from '@/lib/workspace-events/emitter'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployOrchestration')
/**
* Notifies the socket server that a workflow's deployment state has changed,
* so all connected clients can refresh their deployment queries.
*/
async function notifySocketDeploymentChanged(workflowId: string): Promise<void> {
try {
const response = await fetch(`${getSocketServerUrl()}/api/workflow-deployed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.INTERNAL_API_SECRET,
},
body: JSON.stringify({ workflowId }),
})
if (!response.ok) {
logger.warn(
`Socket deployment notification failed (${response.status}) for workflow ${workflowId}`
)
}
} catch (error) {
logger.error('Error sending workflow deployed event to socket server', error)
type DeploymentReadinessSummaryStatus = DeploymentComponentStatus | 'not_applicable'
export interface ActiveDeploymentResult {
deploymentVersionId: string
version: number
deployedAt: string
}
export interface DeploymentAttemptResult {
id: string
deploymentVersionId: string
version: number
action: 'deploy' | 'activate'
status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded'
readiness: {
webhooks: DeploymentReadinessSummaryStatus
schedules: DeploymentReadinessSummaryStatus
mcp: DeploymentReadinessSummaryStatus
}
requestedAt: string
activatedAt?: string | null
error?: {
code: string
message: string
retryable: boolean
} | null
}
export interface PerformFullDeployParams {
workflowId: string
userId: string
workflowName?: string
/**
* Optional summary of what changed, stored on the created deployment version.
* The copilot deploy tools require this; the UI deploy route sets it
@@ -71,11 +89,6 @@ export interface PerformFullDeployParams {
*/
versionName?: string
requestId?: string
/**
* Optional NextRequest for external webhook subscriptions.
* If not provided, a synthetic request is constructed from the base URL.
*/
request?: NextRequest
/**
* Override the actor ID used in audit logs and the `deployedBy` field.
* Defaults to `userId`. Use `'admin-api'` for admin-initiated actions.
@@ -88,21 +101,21 @@ export interface PerformFullDeployResult {
deployedAt?: Date
version?: number
deploymentVersionId?: string
activeDeployment?: ActiveDeploymentResult | null
latestDeploymentAttempt?: DeploymentAttemptResult | null
error?: string
errorCode?: OrchestrationErrorCode
warnings?: string[]
}
/**
* Performs a full workflow deployment: creates a deployment version, queues
* external side effects transactionally, processes that outbox event after
* commit, and notifies clients. Both the deploy API route and the copilot
* deploy tools must use this single function so behaviour stays consistent.
* Admits a deployment through the v2 prepare/activate protocol. The candidate
* version remains inactive until every required side effect is ready.
*/
export async function performFullDeploy(
params: PerformFullDeployParams
): Promise<PerformFullDeployResult> {
const { workflowId, userId, workflowName } = params
const { workflowId, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
@@ -116,101 +129,308 @@ export async function performFullDeploy(
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
const workflowData = workflowRecord as Record<string, unknown>
let outboxEventId: string | undefined
try {
return await performStableFullDeploy({
params,
actorId,
requestId,
})
} catch (error) {
logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error })
return {
success: false,
error: getErrorMessage(error, 'Failed to prepare workflow deployment'),
errorCode: 'internal',
}
}
}
const deployResult = await deployWorkflow({
workflowId,
deployedBy: actorId,
workflowName: workflowName || workflowRecord.name || undefined,
description: params.versionDescription,
name: params.versionName,
validateWorkflowState: async (workflowState) => {
const scheduleValidation = validateWorkflowSchedules(workflowState.blocks)
if (!scheduleValidation.isValid) {
return {
success: false,
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
errorCode: 'validation',
}
async function performStableFullDeploy(params: {
params: PerformFullDeployParams
actorId: string
requestId: string
}): Promise<PerformFullDeployResult> {
const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId)
if (!workflowState) {
return {
success: false,
error: 'Failed to load workflow state',
errorCode: 'validation',
}
}
const validation = await validateDeploymentState(workflowState.blocks)
if (!validation.success) return validation
let outboxEventId: string | undefined
const prepared = await prepareWorkflowDeployment({
workflowId: params.params.workflowId,
actorId: params.actorId,
requestHash: createDeploymentRequestHash({
action: 'deploy',
workflowId: params.params.workflowId,
userId: params.params.userId,
workflowState,
versionName: params.params.versionName ?? null,
versionDescription: params.params.versionDescription ?? null,
}),
idempotencyKey: params.requestId,
workflowState,
name: params.params.versionName,
description: params.params.versionDescription,
readinessComponents: DEPLOYMENT_READINESS_COMPONENTS,
onPrepareTransaction: async (tx, operation) => {
if (!operation.deploymentVersionId || operation.version === null) {
throw new Error('Prepared deployment operation is missing its target version')
}
const triggerValidation = await validateTriggerWebhookConfigForDeploy(workflowState.blocks)
if (!triggerValidation.success) {
return {
success: false,
error: triggerValidation.error?.message || 'Invalid trigger configuration',
errorCode: 'validation',
}
}
return { success: true }
},
onDeployTransaction: async (tx, result) => {
outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
workflowId,
deploymentVersionId: result.deploymentVersionId,
userId,
requestId,
outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, {
protocolVersion: operation.protocolVersion,
operationId: operation.id,
generation: operation.generation,
workflowId: operation.workflowId,
deploymentVersionId: operation.deploymentVersionId,
version: operation.version,
userId: params.params.userId,
requestId: params.requestId,
checkpoints: {},
})
},
})
if (!deployResult.success) {
const error = deployResult.error || 'Failed to deploy workflow'
if (!prepared.success) {
return {
success: false,
error,
errorCode: deployResult.errorCode,
error: prepared.error,
errorCode: mapPrepareFailureCode(prepared.reason),
}
}
const deployedAt = deployResult.deployedAt!
const deploymentVersionId = deployResult.deploymentVersionId
const previousVersionId = deployResult.previousVersionId
const deploymentSnapshot = deployResult.currentState
if (!deploymentVersionId || !deploymentSnapshot) {
await undeployWorkflow({ workflowId })
return { success: false, error: 'Failed to resolve deployment version' }
const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId)
const deploymentStatus = await getWorkflowDeploymentStatus(params.params.workflowId)
const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, deploymentStatus)
if (inlineFailure) return inlineFailure
const result = buildStableDeploymentResult(deploymentStatus, processResult)
/**
* The top-level version identifies the snapshot THIS call admitted, even
* while cutover is still pending otherwise callers would attribute the
* deploy to the previous live version. `activeDeployment` keeps reporting
* what is actually live.
*/
return {
...result,
version: prepared.operation.version,
deploymentVersionId: prepared.operation.deploymentVersionId,
}
}
recordAudit({
workspaceId: (workflowData.workspaceId as string) || null,
actorId: actorId,
action: AuditAction.WORKFLOW_DEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: (workflowData.name as string) || undefined,
description: `Deployed workflow "${(workflowData.name as string) || workflowId}"`,
metadata: {
deploymentVersionId,
version: deployResult.version,
previousVersionId: previousVersionId || undefined,
},
request: params.request,
})
/**
* Surfaces a synchronous failure when the attempt created by this request
* already failed terminally, so callers get an error response instead of a
* success payload with a buried failed status.
*/
function buildInlinePreparationFailure(
operationId: string,
status: WorkflowDeploymentStatus
): { success: false; error: string; errorCode: OrchestrationErrorCode } | null {
const latest = status.latestOperation
if (!latest || latest.id !== operationId || latest.status !== 'failed') return null
return {
success: false,
error: latest.errorMessage || 'Deployment preparation failed',
errorCode:
latest.errorCode === DEPLOYMENT_ERROR_CODES.webhookPathConflict
? 'conflict'
: latest.errorCode === DEPLOYMENT_ERROR_CODES.invalidTriggerConfiguration
? 'validation'
: 'internal',
}
}
const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId)
await notifySocketDeploymentChanged(workflowId)
async function validateDeploymentState(
blocks: Record<string, BlockState>
): Promise<
| { success: true }
| { success: false; error: string; errorCode: Extract<OrchestrationErrorCode, 'validation'> }
> {
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return {
success: false,
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
errorCode: 'validation',
}
}
const triggerValidation = await validateTriggerWebhookConfigForDeploy(blocks)
if (!triggerValidation.success) {
return {
success: false,
error: triggerValidation.error?.message || 'Invalid trigger configuration',
errorCode: 'validation',
}
}
return { success: true }
}
const workspaceId = workflowData.workspaceId as string | null
if (workspaceId) {
void emitWorkflowDeployedEvent({
workflowId,
workflowName: (workflowData.name as string) || workflowId,
workspaceId,
version: deployResult.version ?? null,
function createDeploymentRequestHash(value: Record<string, unknown>): string {
return sha256Hex(JSON.stringify(value))
}
function mapPrepareFailureCode(
reason:
| 'workflow_not_found'
| 'workflow_archived'
| 'deployment_version_not_found'
| 'idempotency_conflict'
| 'invalid_request'
): OrchestrationErrorCode {
if (reason === 'workflow_not_found' || reason === 'deployment_version_not_found') {
return 'not_found'
}
if (reason === 'idempotency_conflict') return 'conflict'
return 'validation'
}
async function processStableDeploymentPreparationNow(
outboxEventId: string | undefined,
requestId: string
): Promise<string | undefined> {
if (!outboxEventId) return undefined
try {
return await processWorkflowDeploymentOutboxEvent(outboxEventId)
} catch (error) {
logger.warn(`[${requestId}] Inline deployment preparation errored; outbox will retry`, {
outboxEventId,
error,
})
return 'processing_error'
}
}
function buildStableDeploymentResult(
status: WorkflowDeploymentStatus,
processResult: string | undefined
): PerformFullDeployResult {
const activeDeployment = status.activeDeployment
? {
deploymentVersionId: status.activeDeployment.deploymentVersionId,
version: status.activeDeployment.version,
deployedAt: status.activeDeployment.deployedAt.toISOString(),
}
: null
const latestDeploymentAttempt = summarizeDeploymentOperation(status.latestOperation)
const warning = getStableDeploymentWarning(
latestDeploymentAttempt,
processResult,
activeDeployment !== null
)
return {
success: true,
deployedAt,
version: deployResult.version,
deploymentVersionId,
warnings: sideEffectWarning ? [sideEffectWarning] : undefined,
deployedAt: status.activeDeployment?.deployedAt,
version: status.activeDeployment?.version,
deploymentVersionId: status.activeDeployment?.deploymentVersionId,
activeDeployment,
latestDeploymentAttempt,
warnings: warning ? [warning] : undefined,
}
}
/**
* Returns the active deployment and latest attempt without mutating deployment state.
*/
export async function getWorkflowDeploymentSummary(workflowId: string): Promise<{
activeDeployment: ActiveDeploymentResult | null
latestDeploymentAttempt: DeploymentAttemptResult | null
warnings?: string[]
}> {
const result = buildStableDeploymentResult(
await getWorkflowDeploymentStatus(workflowId),
undefined
)
return {
activeDeployment: result.activeDeployment ?? null,
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
warnings: result.warnings,
}
}
function summarizeDeploymentOperation(
operation: WorkflowDeploymentOperation | null
): DeploymentAttemptResult | null {
if (!operation) return null
if (
!isDeploymentOperationAction(operation.action) ||
!isDeploymentOperationStatus(operation.status)
) {
return null
}
const readiness = parseDeploymentReadiness(operation.componentReadiness)
const componentStatus = (
component: (typeof DEPLOYMENT_READINESS_COMPONENTS)[number]
): DeploymentReadinessSummaryStatus => readiness?.[component]?.status ?? 'not_applicable'
return {
id: operation.id,
deploymentVersionId: operation.deploymentVersionId,
version: operation.version,
action: operation.action,
status: operation.status,
readiness: {
webhooks: componentStatus('webhooks'),
schedules: componentStatus('schedules'),
mcp: componentStatus('mcp'),
},
requestedAt: operation.createdAt.toISOString(),
activatedAt:
operation.status === 'active' ? (operation.completedAt?.toISOString() ?? null) : null,
error:
operation.errorCode && operation.errorMessage
? {
code: operation.errorCode,
message: operation.errorMessage,
retryable: !isNonRetryableDeploymentErrorCode(operation.errorCode),
}
: null,
}
}
function getStableDeploymentWarning(
attempt: DeploymentAttemptResult | null,
processResult: string | undefined,
hasActiveDeployment: boolean
): string | undefined {
if (!attempt) return undefined
if (attempt.status === 'preparing' || attempt.status === 'activating') {
if (processResult === 'processing_error') {
return hasActiveDeployment
? 'Deployment preparation hit an error and will retry automatically. The prior workflow version remains active until cutover.'
: 'Deployment preparation hit an error and will retry automatically. The workflow remains undeployed until activation.'
}
return hasActiveDeployment
? 'Deployment preparation is queued and may finish shortly. The prior workflow version remains active until cutover.'
: 'Deployment preparation is queued and may finish shortly. The workflow remains undeployed until activation.'
}
if (attempt.status === 'failed') {
return hasActiveDeployment
? 'Deployment preparation failed. The prior workflow version remains active.'
: 'Deployment preparation failed. The workflow remains undeployed.'
}
if (attempt.status === 'superseded') {
return 'This deployment attempt was superseded by a newer request.'
}
if (processResult === 'dead_letter' || processResult === 'not_found') {
return 'Deployment activation completed, but its post-activation event could not be retried automatically.'
}
if (
processResult === 'pending' ||
processResult === 'processing' ||
processResult === 'lease_lost'
) {
return 'Deployment activation completed, and post-activation notifications are queued.'
}
return undefined
}
export interface PerformFullUndeployParams {
workflowId: string
userId: string
@@ -304,9 +524,7 @@ export interface PerformActivateVersionParams {
workflowId: string
version: number
userId: string
workflow: Record<string, unknown>
requestId?: string
request?: NextRequest
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
}
@@ -314,6 +532,8 @@ export interface PerformActivateVersionParams {
export interface PerformActivateVersionResult {
success: boolean
deployedAt?: Date
activeDeployment?: ActiveDeploymentResult | null
latestDeploymentAttempt?: DeploymentAttemptResult | null
error?: string
errorCode?: OrchestrationErrorCode
warnings?: string[]
@@ -339,15 +559,12 @@ export interface PerformRevertToVersionResult {
}
/**
* Activates an existing deployment version: validates schedules, activates the
* version, queues external side effects transactionally, processes that outbox
* event after commit, and records an audit entry. Both the deployment version
* PATCH handler and the admin activate route must use this function.
* Admits an existing version through the v2 prepare/activate protocol.
*/
export async function performActivateVersion(
params: PerformActivateVersionParams
): Promise<PerformActivateVersionResult> {
const { workflowId, version, userId, workflow } = params
const { workflowId, version, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
@@ -377,7 +594,15 @@ export async function performActivateVersion(
.where(eq(workflowTable.id, workflowId))
.limit(1)
return { success: true, deployedAt: workflowDeployment?.deployedAt ?? new Date(), warnings: [] }
const status = await getWorkflowDeploymentStatus(workflowId)
const stableResult = buildStableDeploymentResult(status, 'completed')
return {
success: true,
deployedAt: stableResult.deployedAt ?? workflowDeployment?.deployedAt ?? new Date(),
activeDeployment: stableResult.activeDeployment,
latestDeploymentAttempt: stableResult.latestDeploymentAttempt,
warnings: stableResult.warnings,
}
}
const deployedState = versionRow.state as { blocks?: Record<string, unknown> }
@@ -406,56 +631,88 @@ export async function performActivateVersion(
}
}
try {
return await performStableVersionActivation({
workflowId,
deploymentVersionId: versionRow.id,
version,
userId,
actorId,
requestId,
})
} catch (error) {
logger.error(`[${requestId}] Version activation preparation failed`, {
workflowId,
version,
error,
})
return {
success: false,
error: getErrorMessage(error, 'Failed to prepare version activation'),
errorCode: 'internal',
}
}
}
async function performStableVersionActivation(params: {
workflowId: string
deploymentVersionId: string
version: number
userId: string
actorId: string
requestId: string
}): Promise<PerformActivateVersionResult> {
let outboxEventId: string | undefined
const result = await activateWorkflowVersion({
workflowId,
version,
onActivateTransaction: async (tx, activation) => {
outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
workflowId,
deploymentVersionId: activation.deploymentVersionId,
userId,
requestId,
forceRecreateSubscriptions: true,
const prepared = await prepareWorkflowVersionActivation({
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
actorId: params.actorId,
requestHash: createDeploymentRequestHash({
action: 'activate',
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
version: params.version,
userId: params.userId,
}),
idempotencyKey: params.requestId,
readinessComponents: DEPLOYMENT_READINESS_COMPONENTS,
onPrepareTransaction: async (tx, operation) => {
if (!operation.deploymentVersionId || operation.version === null) {
throw new Error('Prepared activation operation is missing its target version')
}
outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, {
protocolVersion: operation.protocolVersion,
operationId: operation.id,
generation: operation.generation,
workflowId: operation.workflowId,
deploymentVersionId: operation.deploymentVersionId,
version: operation.version,
userId: params.userId,
requestId: params.requestId,
checkpoints: {},
})
},
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to activate version' }
}
recordAudit({
workspaceId: (workflow.workspaceId as string) || null,
actorId: actorId,
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
description: `Activated deployment version ${version}`,
resourceName: (workflow.name as string) || undefined,
metadata: {
version,
deploymentVersionId: versionRow.id,
previousVersionId: result.previousVersionId || undefined,
},
})
const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId)
await notifySocketDeploymentChanged(workflowId)
const activationWorkspaceId = (workflow.workspaceId as string) || null
if (activationWorkspaceId) {
void emitWorkflowDeployedEvent({
workflowId,
workflowName: (workflow.name as string) || workflowId,
workspaceId: activationWorkspaceId,
version,
})
if (!prepared.success) {
return {
success: false,
error: prepared.error,
errorCode: mapPrepareFailureCode(prepared.reason),
}
}
const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId)
const status = await getWorkflowDeploymentStatus(params.workflowId)
const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, status)
if (inlineFailure) return inlineFailure
const result = buildStableDeploymentResult(status, processResult)
return {
success: true,
success: result.success,
deployedAt: result.deployedAt,
warnings: sideEffectWarning ? [sideEffectWarning] : undefined,
activeDeployment: result.activeDeployment,
latestDeploymentAttempt: result.latestDeploymentAttempt,
warnings: result.warnings,
}
}
@@ -3,6 +3,7 @@ export {
performChatUndeploy,
} from './chat-deploy'
export {
getWorkflowDeploymentSummary,
performActivateVersion,
performFullDeploy,
performFullUndeploy,
@@ -0,0 +1,17 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
describe('statusForOrchestrationError', () => {
it.each([
['validation', 400],
['not_found', 404],
['conflict', 409],
['internal', 500],
[undefined, 500],
] as const)('maps %s to %i', (code, expected) => {
expect(statusForOrchestrationError(code)).toBe(expected)
})
})
@@ -7,5 +7,6 @@ export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | '
export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number {
if (code === 'validation') return 400
if (code === 'not_found') return 404
if (code === 'conflict') return 409
return 500
}

Some files were not shown because too many files have changed in this diff Show More