mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
committed by
GitHub
parent
4d6301c900
commit
54b35a4f0e
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
+32
-7
@@ -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>
|
||||
)}
|
||||
|
||||
+149
-62
@@ -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'}
|
||||
|
||||
+15
-9
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user