diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 074255de2ab..162de3ad5f9 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -7059,6 +7059,29 @@ export function SimTriggerIcon(props: SVGProps) { ) } +export function SimDeploymentsIcon(props: SVGProps) { + return ( + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( + +## Usage Instructions + +Deploy, undeploy, and roll back workflows in the current workspace. Promote a previous deployment version to live, list every version, or fetch the deployed workflow state for a specific version. + + + +## Actions + +### `deployments_deploy` + +Deploy a workflow’s current draft state, creating a new deployment version and making it live for API execution. Requires admin permission on the workflow’s workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow to deploy | +| `name` | string | No | Optional label for the new deployment version | +| `description` | string | No | Optional summary of what changed in this version | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the deployed workflow | +| `isDeployed` | boolean | Whether the workflow is now deployed | +| `deployedAt` | string | ISO 8601 timestamp of the deployment \(null if unavailable\) | +| `version` | number | The deployment version that is now active | +| `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule sync still in progress\) | + +### `deployments_undeploy` + +Take a deployed workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Requires admin permission on the workflow’s workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow to undeploy | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the undeployed workflow | +| `isDeployed` | boolean | Whether the workflow is still deployed \(false\) | +| `deployedAt` | string | Always null after an undeploy | +| `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule cleanup still in progress\) | + +### `deployments_promote` + +Make a specific deployment version the live one without creating a new version — the same operation as Promote to live in the deploy modal. Useful for rolling back to a known-good version. Also works on an undeployed workflow: it re-deploys the workflow live at that version. Requires admin permission on the workflow’s workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow | +| `version` | number | Yes | The deployment version number to promote to live | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the workflow | +| `isDeployed` | boolean | Whether the workflow is now deployed | +| `deployedAt` | string | ISO 8601 timestamp of the active deployment \(null if unavailable\) | +| `version` | number | The deployment version that is now live | +| `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule sync still in progress\) | + +### `deployments_list_versions` + +List every deployment version of a workflow, newest first, including which version is currently live. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the workflow | +| `versions` | array | Deployment versions, newest first \(id, version, name, description, isActive, createdAt, createdBy, deployedByName\) | + +### `deployments_get_version` + +Fetch a single deployment version of a workflow, including its metadata and the full workflow state snapshot that was deployed. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow | +| `version` | number | Yes | The deployment version number to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the workflow | +| `version` | number | The deployment version number | +| `name` | string | Version label | +| `description` | string | Version description | +| `isActive` | boolean | Whether this version is currently live | +| `createdAt` | string | When this version was deployed \(ISO 8601\) | +| `deployedState` | json | The full workflow state snapshot \(blocks, edges, loops, parallels, variables\) | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index b40e2f111ea..e67c39642b8 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -42,6 +42,7 @@ "databricks", "datadog", "daytona", + "deployments", "devin", "discord", "docusign", diff --git a/apps/docs/content/docs/en/workflows/deployment/api.mdx b/apps/docs/content/docs/en/workflows/deployment/api.mdx index 60199812d6a..b2f713bed40 100644 --- a/apps/docs/content/docs/en/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/api.mdx @@ -56,6 +56,28 @@ Every time you deploy or update, a new version is recorded in the Versions table **Promote to live** is useful for rolling back — if a new deployment has an issue, promote the previous version to restore the last known-good state instantly. +## Managing Deployments via the API + +Everything above can also be done programmatically. The v1 API exposes deploy, undeploy, and rollback endpoints — useful for CI/CD pipelines that ship a workflow after tests pass, or for reverting to the last known-good version from an incident script. All three require an API key with admin permission on the workflow's workspace. + +```bash +# Deploy the current draft as a new version (body is optional) +curl -X POST https://sim.ai/api/v1/workflows/{workflow-id}/deploy \ + -H "Content-Type: application/json" \ + -H "x-api-key: $SIM_API_KEY" \ + -d '{ "name": "Release 4", "description": "Fixes the agent prompt" }' + +# Undeploy — take the workflow offline +curl -X DELETE https://sim.ai/api/v1/workflows/{workflow-id}/deploy \ + -H "x-api-key: $SIM_API_KEY" + +# Roll back to the previous version (or pass { "version": N } for a specific one) +curl -X POST https://sim.ai/api/v1/workflows/{workflow-id}/rollback \ + -H "x-api-key: $SIM_API_KEY" +``` + +Rollback re-activates an existing deployment version — the same operation as **Promote to live** — and leaves your canvas draft untouched. See the API reference for full request and response details: [Deploy Workflow](/api-reference/workflows/deployWorkflow), [Undeploy Workflow](/api-reference/workflows/undeployWorkflow), and [Rollback Workflow](/api-reference/workflows/rollbackWorkflow). + ## Making API Calls Switch to the **API** tab in the Deploy modal to see ready-to-use code for all three execution modes: @@ -226,8 +248,8 @@ Workflow execution responses are capped by platform request and response limits. "id": "lv_abc123DEF456", "kind": "array", "size": 12582912, - "key": "execution/workspace-id/workflow-id/exec_xyz/large-value-lv_abc123DEF456.json", - "executionId": "exec_xyz", + "key": "execution/workspace-id/workflow-id/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74/large-value-lv_abc123DEF456.json", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "preview": { "length": 25000 } } ``` @@ -254,7 +276,7 @@ curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ "success": true, "async": true, "jobId": "run_abc123", - "executionId": "exec_xyz", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "message": "Workflow execution queued", "statusUrl": "https://sim.ai/api/jobs/run_abc123" } diff --git a/apps/docs/content/docs/en/workflows/triggers/sim.mdx b/apps/docs/content/docs/en/workflows/triggers/sim.mdx index c46b54ed5a4..24487e89dac 100644 --- a/apps/docs/content/docs/en/workflows/triggers/sim.mdx +++ b/apps/docs/content/docs/en/workflows/triggers/sim.mdx @@ -18,6 +18,7 @@ Pick one event per Sim trigger block:
  • Execution Error: a watched workflow's run failed
  • Execution Success: a watched workflow's run completed successfully
  • Workflow Deployed: a watched workflow was deployed (including redeploys and version rollbacks)
  • +
  • Workflow Undeployed: a watched workflow was taken offline
  • **Alert conditions** — evaluated as runs complete (failure-based conditions evaluate on failed runs), with a cooldown so they fire at most once per cooldown window: @@ -60,6 +61,7 @@ All events include `event`, `timestamp`, `workflowId`, and `workflowName` (the s
    • version: the deployment version number that was activated
    +

    Workflow Undeployed carries only the base fields.

    diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 467626104a0..49d6e7ed91b 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -82,7 +82,7 @@ "description": "The unique identifier of the deployed workflow to execute.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } } ], @@ -133,7 +133,7 @@ }, "example": { "success": true, - "executionId": "exec_abc123", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "output": { "content": "The weather in Tokyo is sunny, 22\u00b0C." }, @@ -197,7 +197,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -207,7 +207,7 @@ "description": "The unique identifier of the execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } }, { @@ -343,7 +343,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -353,7 +353,7 @@ "description": "The unique identifier of the execution to cancel.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -377,7 +377,7 @@ }, "example": { "success": true, - "executionId": "exec_abc123" + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" } } } @@ -413,7 +413,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -447,8 +447,8 @@ "pausedExecutions": [ { "id": "pe_abc123", - "workflowId": "wf_1a2b3c4d5e", - "executionId": "exec_9f8e7d6c5b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", "status": "paused", "totalPauseCount": 1, "resumedCount": 0, @@ -465,11 +465,11 @@ "resumeStatus": "paused", "snapshotReady": true, "resumeLinks": { - "apiUrl": "https://www.sim.ai/api/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b/ctx_xyz789", - "uiUrl": "https://www.sim.ai/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b", + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", "contextId": "ctx_xyz789", - "executionId": "exec_9f8e7d6c5b", - "workflowId": "wf_1a2b3c4d5e" + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "response": { "displayData": { @@ -523,7 +523,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -533,7 +533,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -582,7 +582,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -592,7 +592,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -657,7 +657,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -667,7 +667,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } }, { @@ -724,7 +724,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -734,7 +734,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } }, { @@ -831,7 +831,7 @@ "value": { "success": true, "status": "completed", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "output": { "result": "Approved and processed" }, @@ -847,7 +847,7 @@ "summary": "Queued behind another resume", "value": { "status": "queued", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "queuePosition": 2, "message": "Resume queued. It will run after current resumes finish." } @@ -856,7 +856,7 @@ "summary": "Execution started (fire and forget)", "value": { "status": "started", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "message": "Resume execution started." } } @@ -875,7 +875,7 @@ "success": true, "async": true, "jobId": "job_4a3b2c1d0e", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "message": "Resume execution queued", "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" } @@ -1011,7 +1011,7 @@ "example": { "data": [ { - "id": "wf_abc123", + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Weather Bot", "isDeployed": true, "createdAt": "2026-01-15T10:30:00Z", @@ -1057,7 +1057,7 @@ "description": "The unique workflow identifier.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } } ], @@ -1081,7 +1081,7 @@ }, "example": { "data": { - "id": "wf_abc123", + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Weather Bot", "description": "A workflow that fetches weather data", "isDeployed": true, @@ -1104,6 +1104,311 @@ } } }, + "/api/v1/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates any schedules and triggers. Optionally accepts a name and description for the new version. Requires admin permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v1/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The resulting deployment state of the workflow.", + "$ref": "#/components/schemas/WorkflowDeployment" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00Z", + "version": 4, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "description": "The workflow is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Requires admin permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v1/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The resulting deployment state of the workflow.", + "$ref": "#/components/schemas/WorkflowDeployment" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "description": "The workflow is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/v1/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Requires admin permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v1/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the previous version.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3, + "maximum": 2147483647 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The resulting deployment state of the workflow.", + "$ref": "#/components/schemas/WorkflowDeployment" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00Z", + "version": 3, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "description": "The workflow is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, "/api/jobs/{jobId}": { "get": { "operationId": "getJobStatus", @@ -1358,7 +1663,7 @@ "data": [ { "id": "log_abc123", - "workflowId": "wf_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "level": "info", "trigger": "api", "createdAt": "2026-01-15T10:30:00Z" @@ -1427,8 +1732,8 @@ "example": { "data": { "id": "log_abc123", - "workflowId": "wf_abc123", - "executionId": "exec_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "level": "info", "trigger": "api", "totalDurationMs": 1250, @@ -1474,7 +1779,7 @@ "description": "The unique execution identifier.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -1553,8 +1858,8 @@ } }, "example": { - "executionId": "exec_abc123", - "workflowId": "wf_abc123", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "workflowState": {}, "executionMetadata": {} } @@ -1776,7 +2081,7 @@ "actorName": "Jane Doe", "actorEmail": "jane@example.com", "resourceType": "workflow", - "resourceId": "wf_abc123", + "resourceId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "metadata": {}, "createdAt": "2026-01-15T10:30:00Z" } @@ -1911,7 +2216,7 @@ "data": { "tables": [ { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2060,7 +2365,7 @@ "success": true, "data": { "table": { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2150,7 +2455,7 @@ "success": true, "data": { "table": { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2728,7 +3033,7 @@ "data": { "rows": [ { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe" @@ -2896,7 +3201,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe", @@ -3020,7 +3325,7 @@ "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_abc123\", \"row_def456\"]\n }'" + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" } ], "parameters": [ @@ -3079,7 +3384,10 @@ }, "example": { "workspaceId": "wsp_abc123", - "rowIds": ["row_abc123", "row_def456"] + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } @@ -3135,7 +3443,10 @@ "data": { "message": "Rows deleted successfully", "deletedCount": 2, - "deletedRowIds": ["row_abc123", "row_def456"] + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } @@ -3168,7 +3479,7 @@ "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"updates\": [\n { \"rowId\": \"row_abc123\", \"data\": { \"status\": \"active\" } },\n { \"rowId\": \"row_def456\", \"data\": { \"status\": \"archived\" } }\n ]\n }'" + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"updates\": [\n { \"rowId\": \"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"data\": { \"status\": \"active\" } },\n { \"rowId\": \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\", \"data\": { \"status\": \"archived\" } }\n ]\n }'" } ], "parameters": [ @@ -3215,13 +3526,13 @@ "workspaceId": "wsp_abc123", "updates": [ { - "rowId": "row_abc123", + "rowId": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "status": "active" } }, { - "rowId": "row_def456", + "rowId": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", "data": { "status": "archived" } @@ -3306,7 +3617,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe" @@ -3418,7 +3729,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Updated Name" @@ -3636,7 +3947,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "user@example.com", "name": "John Doe" @@ -3724,7 +4035,7 @@ "data": { "files": [ { - "id": "file_abc123", + "id": "wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", "size": 1024, "type": "text/csv", @@ -3820,7 +4131,7 @@ "success": true, "data": { "file": { - "id": "file_abc123", + "id": "wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", "size": 1024, "type": "text/csv", @@ -3888,7 +4199,7 @@ "description": "The unique identifier of the file.", "schema": { "type": "string", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" } }, { @@ -3987,7 +4298,7 @@ "description": "The unique identifier of the file to delete.", "schema": { "type": "string", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" } }, { @@ -4100,7 +4411,7 @@ "data": { "knowledgeBases": [ { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Product Docs", "description": "Product documentation and FAQs", "docCount": 5, @@ -4211,7 +4522,7 @@ "success": true, "data": { "knowledgeBase": { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Product Docs", "description": "Product documentation and FAQs", "docCount": 0, @@ -4298,7 +4609,7 @@ "success": true, "data": { "knowledgeBase": { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Product Docs", "description": "Product documentation and FAQs", "docCount": 5, @@ -4421,7 +4732,7 @@ "success": true, "data": { "knowledgeBase": { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Updated Product Docs", "description": "Updated product documentation", "docCount": 5, @@ -4677,8 +4988,8 @@ "data": { "documents": [ { - "id": "doc_abc123", - "knowledgeBaseId": "kb_abc123", + "id": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", + "knowledgeBaseId": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "filename": "Getting Started.pdf", "fileSize": 204800, "mimeType": "application/pdf", @@ -4795,8 +5106,8 @@ "success": true, "data": { "document": { - "id": "doc_abc123", - "knowledgeBaseId": "kb_abc123", + "id": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", + "knowledgeBaseId": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "filename": "Getting Started.pdf", "fileSize": 204800, "mimeType": "application/pdf", @@ -4943,8 +5254,8 @@ "success": true, "data": { "document": { - "id": "doc_abc123", - "knowledgeBaseId": "kb_abc123", + "id": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", + "knowledgeBaseId": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "filename": "Getting Started.pdf", "fileSize": 204800, "mimeType": "application/pdf", @@ -5122,7 +5433,7 @@ }, "example": { "workspaceId": "wsp_abc123", - "knowledgeBaseIds": ["kb_abc123"], + "knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"], "query": "How do I reset my password?", "topK": 5 } @@ -5180,7 +5491,7 @@ "data": { "results": [ { - "documentId": "doc_abc123", + "documentId": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", "documentName": "Getting Started.pdf", "sourceUrl": "https://example.atlassian.net/wiki/spaces/DOCS/pages/12345", "content": "To reset your password, go to Settings > Security.", @@ -5190,7 +5501,7 @@ } ], "query": "How do I reset my password?", - "knowledgeBaseIds": ["kb_abc123"], + "knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"], "topK": 5, "totalResults": 1 } @@ -5234,7 +5545,7 @@ "required": true, "schema": { "type": "string", - "example": "tbl_abc123" + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" }, "description": "The unique identifier of the table." }, @@ -5244,7 +5555,7 @@ "required": true, "schema": { "type": "string", - "example": "row_xyz789" + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" }, "description": "The unique identifier of the row." }, @@ -5294,7 +5605,7 @@ "id": { "type": "string", "description": "Unique table identifier.", - "example": "tbl_abc123" + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" }, "name": { "type": "string", @@ -5346,7 +5657,7 @@ "id": { "type": "string", "description": "Unique row identifier.", - "example": "row_xyz789" + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" }, "data": { "type": "object", @@ -5376,7 +5687,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -5393,12 +5704,12 @@ "type": "string", "nullable": true, "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "folder_abc123" + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" }, "workspaceId": { "type": "string", "description": "The workspace this workflow belongs to.", - "example": "ws_xyz789" + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, "isDeployed": { "type": "boolean", @@ -5445,7 +5756,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -5462,12 +5773,12 @@ "type": "string", "nullable": true, "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "folder_abc123" + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" }, "workspaceId": { "type": "string", "description": "The workspace this workflow belongs to.", - "example": "ws_xyz789" + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, "isDeployed": { "type": "boolean", @@ -5524,6 +5835,41 @@ } } }, + "WorkflowDeployment": { + "type": "object", + "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", + "example": "2026-06-12T10:30:00Z" + }, + "version": { + "type": "integer", + "description": "The deployment version that is now active. Omitted for undeploy.", + "example": 4 + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." + } + } + }, "ExecutionResult": { "type": "object", "description": "Result of a synchronous workflow execution.", @@ -5536,7 +5882,7 @@ "executionId": { "type": "string", "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "output": { "type": "object", @@ -5599,7 +5945,7 @@ "executionId": { "type": "string", "description": "Unique execution identifier. Use this to query execution status or cancel.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "message": { "type": "string", @@ -5626,12 +5972,12 @@ "workflowId": { "type": "string", "description": "The workflow that was executed.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "executionId": { "type": "string", "description": "Unique execution identifier for this run.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "level": { "type": "string", @@ -5691,12 +6037,12 @@ "workflowId": { "type": "string", "description": "The workflow that was executed.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "executionId": { "type": "string", "description": "Unique execution identifier for this run.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "level": { "type": "string", @@ -5732,7 +6078,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -6121,7 +6467,7 @@ "type": "string", "nullable": true, "description": "The workspace where the action occurred.", - "example": "ws_xyz789" + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, "actorId": { "type": "string", @@ -6155,7 +6501,7 @@ "type": "string", "nullable": true, "description": "The unique identifier of the affected resource.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "resourceName": { "type": "string", @@ -6279,7 +6625,7 @@ "id": { "type": "string", "description": "Unique file identifier.", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" }, "name": { "type": "string", @@ -7090,7 +7436,10 @@ "data": { "message": "Rows updated successfully", "updatedCount": 2, - "updatedRowIds": ["row_abc123", "row_def456"] + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts new file mode 100644 index 00000000000..74d87926bc4 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsDeployContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performFullDeploy } from '@/lib/workflows/orchestration' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsDeployContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId, name, description } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') + if (!access.ok) return access.response + + await assertWorkflowMutable(workflowId) + + logger.info(`[${requestId}] Deploying workflow ${workflowId} via deployments tool`, { + userId: auth.userId, + }) + + const result = await performFullDeploy({ + workflowId, + userId: auth.userId, + workflowName: access.workflow.name || undefined, + versionName: name, + versionDescription: description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + const status = + result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 + return deploymentToolError(result.error || 'Failed to deploy workflow', status) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return deploymentToolError(error.message, error.status) + } + logger.error(`[${requestId}] Deployment tool deploy error`, { error }) + return deploymentToolError('Failed to deploy workflow', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts new file mode 100644 index 00000000000..e433f730b55 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsPromoteContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsPromoteAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsPromoteContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId, version } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') + if (!access.ok) return access.response + + await assertWorkflowMutable(workflowId) + + logger.info( + `[${requestId}] Promoting workflow ${workflowId} to version ${version} via deployments tool`, + { userId: auth.userId } + ) + + const result = await performActivateVersion({ + workflowId, + version, + userId: auth.userId, + workflow: access.workflow as Record, + requestId, + request, + }) + + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return deploymentToolError(result.error || 'Failed to promote deployment version', status) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version, + warnings: result.warnings ?? [], + }, + }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return deploymentToolError(error.message, error.status) + } + logger.error(`[${requestId}] Deployment tool promote error`, { error }) + return deploymentToolError('Failed to promote deployment version', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/routes.test.ts b/apps/sim/app/api/tools/deployments/routes.test.ts new file mode 100644 index 00000000000..bf75b419fcd --- /dev/null +++ b/apps/sim/app/api/tools/deployments/routes.test.ts @@ -0,0 +1,363 @@ +/** + * @vitest-environment node + * + * Tests for the deployment tool routes under /api/tools/deployments — verifies + * session/internal auth, workspace permission enforcement, and the mapping of + * orchestration results to tool responses. + */ +import { db } from '@sim/db' +import { createMockRequest, hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing' +import { WorkflowLockedError } from '@sim/workflow-authz' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockEnforceUserRateLimit, + mockPerformFullDeploy, + mockPerformFullUndeploy, + mockPerformActivateVersion, + mockListWorkflowVersions, +} = vi.hoisted(() => ({ + mockEnforceUserRateLimit: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), + mockPerformActivateVersion: vi.fn(), + mockListWorkflowVersions: vi.fn(), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: mockPerformFullUndeploy, + performActivateVersion: mockPerformActivateVersion, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + listWorkflowVersions: mockListWorkflowVersions, +})) + +import { POST as deployPost } from '@/app/api/tools/deployments/deploy/route' +import { POST as promotePost } from '@/app/api/tools/deployments/promote/route' +import { POST as undeployPost } from '@/app/api/tools/deployments/undeploy/route' +import { GET as getVersionGet } from '@/app/api/tools/deployments/version/route' +import { GET as listVersionsGet } from '@/app/api/tools/deployments/versions/route' + +const WORKFLOW_ID = 'wf-1' +const WORKFLOW_RECORD = { + id: WORKFLOW_ID, + name: 'My Workflow', + workspaceId: 'ws-1', + isDeployed: true, +} + +function authorized() { + return { allowed: true, status: 200, workflow: WORKFLOW_RECORD, workspacePermission: 'admin' } +} + +function makePost(path: string, body: unknown) { + return createMockRequest('POST', body, {}, `http://localhost:3000/api/tools/deployments/${path}`) +} + +function makeGet(path: string, query: string) { + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/tools/deployments/${path}?${query}` + ) +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockEnforceUserRateLimit.mockResolvedValue(null) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue(authorized()) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) +}) + +describe('POST /api/tools/deployments/deploy', () => { + beforeEach(() => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + version: 4, + }) + }) + + it('rejects unauthenticated requests', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) + + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(401) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('requires admin permission on the workflow workspace', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Access denied', + workflow: WORKFLOW_RECORD, + workspacePermission: 'write', + }) + + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(403) + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'admin', + }) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('deploys and returns the new version', async () => { + const response = await deployPost( + makePost('deploy', { + workflowId: WORKFLOW_ID, + workspaceId: 'ws-1', + name: 'Release 4', + description: 'Fixes the agent prompt', + }) + ) + + expect(response.status).toBe(200) + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + versionName: 'Release 4', + versionDescription: 'Fixes the agent prompt', + }) + ) + + const body = await response.json() + expect(body).toEqual({ + success: true, + output: { + workflowId: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 4, + warnings: [], + }, + }) + }) + + it('returns 423 when the workflow is locked', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError()) + + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(423) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('rejects a request without a workflowId', async () => { + const response = await deployPost(makePost('deploy', { workspaceId: 'ws-1' })) + + expect(response.status).toBe(400) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow belongs to a different workspace', async () => { + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-other' }) + ) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body.error).toBe('Workflow not found in this workspace') + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/tools/deployments/undeploy', () => { + beforeEach(() => { + mockPerformFullUndeploy.mockResolvedValue({ success: true }) + }) + + it('returns 400 when the workflow is not deployed', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + ...authorized(), + workflow: { ...WORKFLOW_RECORD, isDeployed: false }, + }) + + const response = await undeployPost( + makePost('undeploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(400) + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) + + it('undeploys a deployed workflow', async () => { + const response = await undeployPost( + makePost('undeploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(200) + expect(mockPerformFullUndeploy).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.output).toEqual({ + workflowId: WORKFLOW_ID, + isDeployed: false, + deployedAt: null, + warnings: [], + }) + }) +}) + +describe('POST /api/tools/deployments/promote', () => { + beforeEach(() => { + mockPerformActivateVersion.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + }) + }) + + it('promotes the given version to live', async () => { + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 3 }) + ) + + expect(response.status).toBe(200) + expect(mockPerformActivateVersion).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, version: 3, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.output).toEqual({ + workflowId: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 3, + warnings: [], + }) + }) + + it('rejects a missing version', async () => { + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(400) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('maps a missing target version to 404', async () => { + mockPerformActivateVersion.mockResolvedValue({ + success: false, + error: 'Deployment version not found', + errorCode: 'not_found', + }) + + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 99 }) + ) + + expect(response.status).toBe(404) + }) +}) + +describe('GET /api/tools/deployments/versions', () => { + it('lists deployment versions with read permission', async () => { + const versions = [ + { + id: 'v-2', + version: 2, + name: null, + description: null, + isActive: true, + createdAt: '2026-06-12T00:00:00.000Z', + createdBy: 'user-1', + deployedByName: 'Waleed', + }, + ] + mockListWorkflowVersions.mockResolvedValue({ versions }) + + const response = await listVersionsGet( + makeGet('versions', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1`) + ) + + expect(response.status).toBe(200) + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'read', + }) + + const body = await response.json() + expect(body.output).toEqual({ workflowId: WORKFLOW_ID, versions }) + }) +}) + +describe('GET /api/tools/deployments/version', () => { + function mockVersionRow(rows: unknown[]) { + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: vi.fn(() => Promise.resolve(rows)) })), + })), + } as never) + } + + it('returns version metadata and the deployed state', async () => { + mockVersionRow([ + { + id: 'v-3', + version: 3, + name: 'Release 3', + description: null, + isActive: false, + createdAt: '2026-06-12T00:00:00.000Z', + state: { blocks: {}, edges: [] }, + }, + ]) + + const response = await getVersionGet( + makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=3`) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.output).toEqual({ + workflowId: WORKFLOW_ID, + version: 3, + name: 'Release 3', + description: null, + isActive: false, + createdAt: '2026-06-12T00:00:00.000Z', + deployedState: { blocks: {}, edges: [] }, + }) + }) + + it('returns 404 when the version does not exist', async () => { + mockVersionRow([]) + + const response = await getVersionGet( + makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=9`) + ) + + expect(response.status).toBe(404) + }) +}) diff --git a/apps/sim/app/api/tools/deployments/undeploy/route.ts b/apps/sim/app/api/tools/deployments/undeploy/route.ts new file mode 100644 index 00000000000..631c02a50d7 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/undeploy/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsUndeployContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performFullUndeploy } from '@/lib/workflows/orchestration' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsUndeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsUndeployContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') + if (!access.ok) return access.response + + if (!access.workflow.isDeployed) { + return deploymentToolError('Workflow is not deployed', 400) + } + + await assertWorkflowMutable(workflowId) + + logger.info(`[${requestId}] Undeploying workflow ${workflowId} via deployments tool`, { + userId: auth.userId, + }) + + const result = await performFullUndeploy({ + workflowId, + userId: auth.userId, + requestId, + }) + + if (!result.success) { + return deploymentToolError(result.error || 'Failed to undeploy workflow', 500) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return deploymentToolError(error.message, error.status) + } + logger.error(`[${requestId}] Deployment tool undeploy error`, { error }) + return deploymentToolError('Failed to undeploy workflow', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/utils.ts b/apps/sim/app/api/tools/deployments/utils.ts new file mode 100644 index 00000000000..b82b4fcdf88 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/utils.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { + authorizeWorkflowByWorkspacePermission, + type WorkflowWorkspaceAuthorizationResult, +} from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserRateLimit } from '@/lib/core/rate-limiter' + +const logger = createLogger('DeploymentToolsAPI') + +export type AuthorizedDeploymentWorkflow = NonNullable< + WorkflowWorkspaceAuthorizationResult['workflow'] +> + +/** Standard error body for deployment tool routes, matching the generic tool response shape. */ +export function deploymentToolError(error: string, status: number): NextResponse { + return NextResponse.json({ success: false, error }, { status }) +} + +/** + * Authenticates a deployment tool request via session or internal token (API + * keys are rejected) and applies per-user rate limiting. Runs before request + * parsing, so it must not read the body. + */ +export async function authenticateDeploymentToolRequest( + request: NextRequest, + requestId: string +): Promise<{ ok: true; userId: string } | { ok: false; response: NextResponse }> { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized deployment tool request`, { error: auth.error }) + return { + ok: false, + response: deploymentToolError(auth.error || 'Authentication required', 401), + } + } + + const rateLimited = await enforceUserRateLimit('deployment-tools', auth.userId) + if (rateLimited) return { ok: false, response: rateLimited } + + return { ok: true, userId: auth.userId } +} + +/** + * Verifies the user holds the required workspace permission on the target + * workflow and that the workflow belongs to the calling workspace. Deployment + * mutations require `admin`, reads require `read`, matching the UI deploy + * routes. The workspace binding keeps workflow-driven executions (schedules, + * webhooks) from reaching into other workspaces the actor administers. + */ +export async function authorizeDeploymentWorkflow( + userId: string, + workflowId: string, + workspaceId: string, + action: 'read' | 'admin' +): Promise< + { ok: true; workflow: AuthorizedDeploymentWorkflow } | { ok: false; response: NextResponse } +> { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action, + }) + + if (!authorization.allowed || !authorization.workflow) { + return { + ok: false, + response: deploymentToolError(authorization.message || 'Access denied', authorization.status), + } + } + + if (authorization.workflow.workspaceId !== workspaceId) { + return { + ok: false, + response: deploymentToolError('Workflow not found in this workspace', 404), + } + } + + return { ok: true, workflow: authorization.workflow } +} diff --git a/apps/sim/app/api/tools/deployments/version/route.ts b/apps/sim/app/api/tools/deployments/version/route.ts new file mode 100644 index 00000000000..21dd85e92a4 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/version/route.ts @@ -0,0 +1,83 @@ +import { db } from '@sim/db' +import { workflowDeploymentVersion } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsGetVersionContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsGetVersionAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsGetVersionContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId, version } = parsed.data.query + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read') + if (!access.ok) return access.response + + const [row] = await db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + state: workflowDeploymentVersion.state, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.version, version) + ) + ) + .limit(1) + + if (!row) { + return deploymentToolError('Deployment version not found', 404) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt, + deployedState: row.state, + }, + }) + } catch (error: unknown) { + logger.error(`[${requestId}] Deployment tool get version error`, { error }) + return deploymentToolError('Failed to get deployment version', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/versions/route.ts b/apps/sim/app/api/tools/deployments/versions/route.ts new file mode 100644 index 00000000000..42abd9eb9ea --- /dev/null +++ b/apps/sim/app/api/tools/deployments/versions/route.ts @@ -0,0 +1,52 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsListVersionsContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsListVersionsAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsListVersionsContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId } = parsed.data.query + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read') + if (!access.ok) return access.response + + const { versions } = await listWorkflowVersions(workflowId) + + return NextResponse.json({ + success: true, + output: { workflowId, versions }, + }) + } catch (error: unknown) { + logger.error(`[${requestId}] Deployment tool list versions error`, { error }) + return deploymentToolError('Failed to list deployment versions', 500) + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts index fb5789356dd..6744ab589b4 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts @@ -41,7 +41,7 @@ export const GET = withRouteHandler( isActive: v.isActive, createdAt: v.createdAt.toISOString(), createdBy: v.createdBy, - deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null), + deployedByName: v.deployedByName, })) logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 6472cbb1f76..94cacfd27f8 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -16,6 +16,8 @@ export type V1Endpoint = | 'logs-detail' | 'workflows' | 'workflow-detail' + | 'workflow-deploy' + | 'workflow-rollback' | 'audit-logs' | 'tables' | 'table-detail' @@ -190,6 +192,9 @@ export async function checkWorkspaceScope( return null } +/** Orders workspace permission levels for at-least comparisons. */ +const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const + /** * Validates workspace-scoped API key bounds and the user's workspace permission. * Returns null on success, NextResponse on failure. @@ -198,16 +203,13 @@ export async function validateWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, - level: 'read' | 'write' = 'read' + level: keyof typeof PERMISSION_RANK = 'read' ): Promise { const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - if (level === 'write' && permission === 'read') { + if (permission === null || PERMISSION_RANK[permission] < PERMISSION_RANK[level]) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } return null diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts new file mode 100644 index 00000000000..3dead585727 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -0,0 +1,270 @@ +/** + * @vitest-environment node + * + * Tests for POST/DELETE /api/v1/workflows/[id]/deploy — verifies auth, + * workspace admin permission enforcement, optional body handling, and the + * mapping of orchestration results to v1 API responses. + */ +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { WorkflowLockedError } from '@sim/workflow-authz' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockValidateWorkspaceAccess, + mockPerformFullDeploy, + mockPerformFullUndeploy, + mockCaptureServerEvent, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockValidateWorkspaceAccess: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), + mockCaptureServerEvent: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + createRateLimitResponse: vi.fn(() => + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ), + validateWorkspaceAccess: mockValidateWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: mockPerformFullUndeploy, +})) + +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn().mockResolvedValue({}), + createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })), +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +import { DELETE, POST } from '@/app/api/v1/workflows/[id]/deploy/route' + +const WORKFLOW_ID = 'wf-1' +const WORKFLOW_RECORD = { + id: WORKFLOW_ID, + name: 'My Workflow', + workspaceId: 'ws-1', + isDeployed: true, +} + +function makeContext(id = WORKFLOW_ID) { + return { params: Promise.resolve({ id }) } +} + +function makeRequest(method: string, body?: unknown) { + return createMockRequest( + method, + body, + {}, + `http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy` + ) +} + +describe('POST /api/v1/workflows/[id]/deploy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformFullDeploy.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + version: 4, + warnings: undefined, + }) + }) + + it('rejects unauthenticated requests', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' }) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(401) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(404) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('masks missing admin permission as 404', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(404) + expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ allowed: true }), + 'user-1', + 'ws-1', + 'admin' + ) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('rejects a malformed JSON body', async () => { + const request = new NextRequest( + new URL(`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy`), + { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: '{"name": "Release 4"', + } + ) + + const response = await POST(request, makeContext()) + + expect(response.status).toBe(400) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('rejects invalid version metadata', async () => { + const response = await POST(makeRequest('POST', { name: '' }), makeContext()) + + expect(response.status).toBe(400) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('deploys without a request body', async () => { + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + versionName: undefined, + versionDescription: undefined, + }) + ) + + const body = await response.json() + expect(body.data).toEqual({ + id: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 4, + warnings: [], + }) + }) + + it('passes version metadata through to the deploy orchestration', async () => { + const response = await POST( + makeRequest('POST', { name: 'Release 4', description: 'Fixes the agent prompt' }), + makeContext() + ) + + expect(response.status).toBe(200) + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + versionName: 'Release 4', + versionDescription: 'Fixes the agent prompt', + }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'workflow_deployed', + expect.objectContaining({ workflow_id: WORKFLOW_ID }), + expect.anything() + ) + }) + + it('maps validation failures from the orchestration to 400', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: false, + error: 'Invalid schedule configuration', + errorCode: 'validation', + }) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid schedule configuration') + }) + + it('returns 423 when the workflow is locked', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError()) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(423) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v1/workflows/[id]/deploy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformFullUndeploy.mockResolvedValue({ success: true }) + }) + + it('returns 400 when the workflow is not deployed', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({ + ...WORKFLOW_RECORD, + isDeployed: false, + }) + + const response = await DELETE(makeRequest('DELETE'), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Workflow is not deployed') + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) + + it('undeploys a deployed workflow', async () => { + const response = await DELETE(makeRequest('DELETE'), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformFullUndeploy).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.data).toEqual({ + id: WORKFLOW_ID, + isDeployed: false, + deployedAt: null, + warnings: [], + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'workflow_undeployed', + expect.objectContaining({ workflow_id: WORKFLOW_ID }), + expect.anything() + ) + }) + + it('masks missing admin permission as 404', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await DELETE(makeRequest('DELETE'), makeContext()) + + expect(response.status).toBe(404) + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..f62421e2e3d --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -0,0 +1,203 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + assertWorkflowMutable, + getActiveWorkflowRecord, + WorkflowLockedError, +} from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { + v1DeployWorkflowBodySchema, + v1DeployWorkflowContract, + v1UndeployWorkflowContract, +} from '@/lib/api/contracts/v1/workflows' +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 { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' + +const logger = createLogger('V1WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1DeployWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) return rawBody.response + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) { + return validationErrorResponse(body.error) + } + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + const workspaceId = workflowData.workspaceId + + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin') + if (accessError) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v1 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + workflowName: workflowData.name || undefined, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + const status = + result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 + return NextResponse.json({ error: result.error || 'Failed to deploy workflow' }, { status }) + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + const limits = await getUserLimits(userId) + const apiResponse = createApiResponse( + { + data: { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + }, + limits, + rateLimit + ) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow deploy error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1UndeployWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + const workspaceId = workflowData.workspaceId + + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin') + if (accessError) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + if (!workflowData.isDeployed) { + return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 }) + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v1 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return NextResponse.json( + { error: result.error || 'Failed to undeploy workflow' }, + { status: 500 } + ) + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + const limits = await getUserLimits(userId) + const apiResponse = createApiResponse( + { + data: { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + }, + limits, + rateLimit + ) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow undeploy error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts new file mode 100644 index 00000000000..c1f085faf02 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + * + * Tests for POST /api/v1/workflows/[id]/rollback — verifies target version + * resolution (previous version by default, explicit version when provided) + * and the mapping of activation results to v1 API responses. + */ +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { WorkflowLockedError } from '@sim/workflow-authz' +import { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockValidateWorkspaceAccess, + mockPerformActivateVersion, + mockFindPreviousDeploymentVersion, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockValidateWorkspaceAccess: vi.fn(), + mockPerformActivateVersion: vi.fn(), + mockFindPreviousDeploymentVersion: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + findPreviousDeploymentVersion: mockFindPreviousDeploymentVersion, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + createRateLimitResponse: vi.fn(() => + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ), + validateWorkspaceAccess: mockValidateWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performActivateVersion: mockPerformActivateVersion, +})) + +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn().mockResolvedValue({}), + createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })), +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + +import { POST } from '@/app/api/v1/workflows/[id]/rollback/route' + +const WORKFLOW_ID = 'wf-1' +const WORKFLOW_RECORD = { + id: WORKFLOW_ID, + name: 'My Workflow', + workspaceId: 'ws-1', + isDeployed: true, +} + +function makeContext(id = WORKFLOW_ID) { + return { params: Promise.resolve({ id }) } +} + +function makeRequest(body?: unknown) { + return createMockRequest( + 'POST', + body, + {}, + `http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/rollback` + ) +} + +describe('POST /api/v1/workflows/[id]/rollback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformActivateVersion.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + }) + }) + + it('rejects unauthenticated requests', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(401) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(404) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 423 when the workflow is locked', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError()) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(423) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('rolls back to the previous version when no version is given', async () => { + mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: true, version: 4 }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformActivateVersion).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, version: 4, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.data).toEqual({ + id: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 4, + warnings: [], + }) + }) + + it('returns 400 when the workflow is not deployed, even with an explicit version', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({ + ...WORKFLOW_RECORD, + isDeployed: false, + }) + + const response = await POST(makeRequest({ version: 2 }), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Workflow is not deployed') + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('rolls back to an explicit version when provided', async () => { + const response = await POST(makeRequest({ version: 2 }), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformActivateVersion).toHaveBeenCalledWith(expect.objectContaining({ version: 2 })) + expect(mockFindPreviousDeploymentVersion).not.toHaveBeenCalled() + }) + + it('rejects a non-integer version', async () => { + const response = await POST(makeRequest({ version: 1.5 }), makeContext()) + + expect(response.status).toBe(400) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 400 when there is no active deployment to roll back from', async () => { + mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: false, reason: 'no_active_version' }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Workflow has no active deployment to roll back from') + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 400 when there is no previous version to roll back to', async () => { + mockFindPreviousDeploymentVersion.mockResolvedValue({ + ok: false, + reason: 'no_previous_version', + }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('No previous deployment version to roll back to') + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('maps a missing target version to 404', async () => { + mockPerformActivateVersion.mockResolvedValue({ + success: false, + error: 'Deployment version not found', + errorCode: 'not_found', + }) + + const response = await POST(makeRequest({ version: 99 }), makeContext()) + + expect(response.status).toBe(404) + }) + + it('masks missing admin permission as 404', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(404) + expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ allowed: true }), + 'user-1', + 'ws-1', + 'admin' + ) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..a29cbd9bd1f --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -0,0 +1,143 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + assertWorkflowMutable, + getActiveWorkflowRecord, + WorkflowLockedError, +} from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { + v1RollbackWorkflowBodySchema, + v1RollbackWorkflowContract, +} from '@/lib/api/contracts/v1/workflows' +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 { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { + checkRateLimit, + createRateLimitResponse, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' + +const logger = createLogger('V1WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1RollbackWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) return rawBody.response + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) { + return validationErrorResponse(body.error) + } + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + const workspaceId = workflowData.workspaceId + + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin') + if (accessError) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + if (!workflowData.isDeployed) { + return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 }) + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return NextResponse.json({ error: message }, { status: 400 }) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v1 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + workflow: workflowData as Record, + requestId, + request, + }) + + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json( + { error: result.error || 'Failed to roll back workflow' }, + { status } + ) + } + + 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, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + }, + limits, + rateLimit + ) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow rollback error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/blocks/blocks/deployments.ts b/apps/sim/blocks/blocks/deployments.ts new file mode 100644 index 00000000000..4a101f1ba33 --- /dev/null +++ b/apps/sim/blocks/blocks/deployments.ts @@ -0,0 +1,200 @@ +import { SimDeploymentsIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' + +export const DeploymentsBlock: BlockConfig = { + type: 'deployments', + name: 'Deployments', + description: 'Manage workflow deployments', + longDescription: + 'Deploy, undeploy, and roll back workflows in the current workspace. Promote a previous deployment version to live, list every version, or fetch the deployed workflow state for a specific version.', + bestPractices: ` + - The block operates on workflows in the current workspace; pick one with the selector or pass an ID. + - Deploy publishes the workflow's current draft as a new live version. Undeploy takes it offline. + - 'Promote Version to Live' re-activates an existing version without creating a new one — use it to roll back to a known-good version. It also works on an undeployed workflow, re-deploying it live at that version. + - Use 'List Versions' to discover version numbers, then feed one into 'Promote Version to Live' or 'Get Version Details'. + - Deploy, undeploy, and promote require admin permission on the workspace; the read operations require workspace access. + `, + bgColor: '#0C0C0C', + iconColor: '#33C482', + icon: SimDeploymentsIcon, + category: 'blocks', + docsLink: 'https://docs.sim.ai/workflows/deployment', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Deploy', id: 'deployments_deploy' }, + { label: 'Undeploy', id: 'deployments_undeploy' }, + { label: 'Promote Version to Live', id: 'deployments_promote' }, + { label: 'List Versions', id: 'deployments_list_versions' }, + { label: 'Get Version Details', id: 'deployments_get_version' }, + ], + value: () => 'deployments_deploy', + }, + { + id: 'workflowSelector', + title: 'Workflow', + type: 'workflow-selector', + selectorKey: 'sim.workflows', + placeholder: 'Select workflow', + mode: 'basic', + canonicalParamId: 'workflowId', + required: true, + }, + { + id: 'manualWorkflowId', + title: 'Workflow ID', + type: 'short-input', + placeholder: 'Workflow ID', + mode: 'advanced', + canonicalParamId: 'workflowId', + required: true, + }, + { + id: 'versionName', + title: 'Version Name', + type: 'short-input', + placeholder: 'Optional label, e.g. "Release 4"', + condition: { field: 'operation', value: 'deployments_deploy' }, + }, + { + id: 'versionDescription', + title: 'Version Description', + type: 'long-input', + placeholder: 'Optional summary of what changed in this version', + condition: { field: 'operation', value: 'deployments_deploy' }, + }, + { + id: 'version', + title: 'Version', + type: 'short-input', + placeholder: 'Deployment version number, e.g. 3', + condition: { + field: 'operation', + value: ['deployments_promote', 'deployments_get_version'], + }, + required: { + field: 'operation', + value: ['deployments_promote', 'deployments_get_version'], + }, + }, + ], + tools: { + access: [ + 'deployments_deploy', + 'deployments_undeploy', + 'deployments_promote', + 'deployments_list_versions', + 'deployments_get_version', + ], + config: { + tool: (params: Record) => params.operation || 'deployments_deploy', + params: (params: Record) => { + const operation = params.operation || 'deployments_deploy' + const workflowId = typeof params.workflowId === 'string' ? params.workflowId.trim() : '' + if (!workflowId) { + throw new Error('Deployments Block Error: Workflow is required') + } + + if (operation === 'deployments_deploy') { + return { + workflowId, + name: params.versionName?.trim() || undefined, + description: params.versionDescription?.trim() || undefined, + } + } + + if (operation === 'deployments_promote' || operation === 'deployments_get_version') { + const version = Number(params.version) + if (!Number.isInteger(version) || version < 1) { + throw new Error('Deployments Block Error: Version must be a positive integer') + } + return { workflowId, version } + } + + return { workflowId } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + workflowId: { type: 'string', description: 'Target workflow (canonical param)' }, + versionName: { type: 'string', description: 'Optional version label (Deploy operation)' }, + versionDescription: { + type: 'string', + description: 'Optional version description (Deploy operation)', + }, + version: { + type: 'number', + description: 'Deployment version number (Promote and Get Version Details operations)', + }, + }, + outputs: { + workflowId: { type: 'string', description: 'ID of the target workflow' }, + isDeployed: { + type: 'boolean', + description: 'Whether the workflow is deployed after the operation', + condition: { + field: 'operation', + value: ['deployments_deploy', 'deployments_undeploy', 'deployments_promote'], + }, + }, + deployedAt: { + type: 'string', + description: 'ISO 8601 timestamp of the active deployment; null after an undeploy', + condition: { + field: 'operation', + value: ['deployments_deploy', 'deployments_undeploy', 'deployments_promote'], + }, + }, + version: { + type: 'number', + description: 'The deployment version number', + condition: { + field: 'operation', + value: ['deployments_deploy', 'deployments_promote', 'deployments_get_version'], + }, + }, + warnings: { + type: 'array', + description: 'Non-fatal warnings (e.g. trigger or schedule sync still in progress)', + condition: { + field: 'operation', + value: ['deployments_deploy', 'deployments_undeploy', 'deployments_promote'], + }, + }, + versions: { + type: 'json', + description: + 'Deployment versions, newest first (id, version, name, description, isActive, createdAt, createdBy, deployedByName)', + condition: { field: 'operation', value: 'deployments_list_versions' }, + }, + name: { + type: 'string', + description: 'Version label', + condition: { field: 'operation', value: 'deployments_get_version' }, + }, + description: { + type: 'string', + description: 'Version description', + condition: { field: 'operation', value: 'deployments_get_version' }, + }, + isActive: { + type: 'boolean', + description: 'Whether this version is currently live', + condition: { field: 'operation', value: 'deployments_get_version' }, + }, + createdAt: { + type: 'string', + description: 'When this version was deployed (ISO 8601)', + condition: { field: 'operation', value: 'deployments_get_version' }, + }, + deployedState: { + type: 'json', + description: 'The full workflow state snapshot (blocks, edges, loops, parallels, variables)', + condition: { field: 'operation', value: 'deployments_get_version' }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 80d833dd174..2cacc4576c6 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -46,6 +46,7 @@ import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' import { DatabricksBlock, DatabricksBlockMeta } from '@/blocks/blocks/databricks' import { DatadogBlock, DatadogBlockMeta } from '@/blocks/blocks/datadog' import { DaytonaBlock, DaytonaBlockMeta } from '@/blocks/blocks/daytona' +import { DeploymentsBlock } from '@/blocks/blocks/deployments' import { DevinBlock, DevinBlockMeta } from '@/blocks/blocks/devin' import { DiscordBlock, DiscordBlockMeta } from '@/blocks/blocks/discord' import { DocuSignBlock, DocuSignBlockMeta } from '@/blocks/blocks/docusign' @@ -376,6 +377,7 @@ const BLOCK_REGISTRY: Record = { databricks: DatabricksBlock, datadog: DatadogBlock, daytona: DaytonaBlock, + deployments: DeploymentsBlock, devin: DevinBlock, discord: DiscordBlock, docusign: DocuSignBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 074255de2ab..162de3ad5f9 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -7059,6 +7059,29 @@ export function SimTriggerIcon(props: SVGProps) { ) } +export function SimDeploymentsIcon(props: SVGProps) { + return ( + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( + +export const deploymentsDeployContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/deployments/deploy', + body: deploymentsDeployBodySchema, + response: { + mode: 'json', + schema: genericToolResponseSchema, + }, +}) + +export const deploymentsUndeployBodySchema = z.object({ + workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, +}) + +export type DeploymentsUndeployBody = z.input + +export const deploymentsUndeployContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/deployments/undeploy', + body: deploymentsUndeployBodySchema, + response: { + mode: 'json', + schema: genericToolResponseSchema, + }, +}) + +export const deploymentsPromoteBodySchema = z.object({ + workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, + version: versionSchema, +}) + +export type DeploymentsPromoteBody = z.input + +export const deploymentsPromoteContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/deployments/promote', + body: deploymentsPromoteBodySchema, + response: { + mode: 'json', + schema: genericToolResponseSchema, + }, +}) + +export const deploymentsListVersionsQuerySchema = z.object({ + workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, +}) + +export type DeploymentsListVersionsQuery = z.input + +export const deploymentsListVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/tools/deployments/versions', + query: deploymentsListVersionsQuerySchema, + response: { + mode: 'json', + schema: genericToolResponseSchema, + }, +}) + +export const deploymentsGetVersionQuerySchema = z.object({ + workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, + version: z.coerce.number().pipe(versionSchema), +}) + +export type DeploymentsGetVersionQuery = z.input + +export const deploymentsGetVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/tools/deployments/version', + query: deploymentsGetVersionQuerySchema, + response: { + mode: 'json', + schema: genericToolResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/tools/index.ts b/apps/sim/lib/api/contracts/tools/index.ts index 58a97c5a48a..7f9a6b5b737 100644 --- a/apps/sim/lib/api/contracts/tools/index.ts +++ b/apps/sim/lib/api/contracts/tools/index.ts @@ -8,6 +8,7 @@ export * from './cursor' export * from './custom' export * from './databases' export * from './daytona' +export * from './deployments' export * from './docusign' export * from './evernote' export * from './file' diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 699a3920dca..5d46dcae135 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { 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' @@ -51,3 +52,93 @@ export const v1GetWorkflowContract = defineRouteContract({ schema: v1WorkflowApiResponseWithLimitsSchema, }, }) + +/** + * Optional version metadata accepted by the v1 deploy endpoint. Field bounds + * are shared with the UI deployment surface via + * {@link deploymentVersionMetadataFieldsSchema}. The route tolerates an + * absent/empty body, so this schema is validated by the handler against + * `parseOptionalJsonBody` instead of being attached to the contract. + */ +export const v1DeployWorkflowBodySchema = z.object({ + name: deploymentVersionMetadataFieldsSchema.shape.name, + description: deploymentVersionMetadataFieldsSchema.shape.description, +}) + +export type V1DeployWorkflowBody = z.input + +/** Bounded to the Postgres `integer` range of `workflow_deployment_version.version`. */ +const deploymentVersionNumberSchema = z + .number() + .int('version must be an integer') + .min(1, 'version must be a positive integer') + .max(2147483647, 'version is out of range') + +/** + * Optional rollback target accepted by the v1 rollback endpoint. When + * `version` is omitted the route rolls back to the deployment version that + * precedes the currently active one. Validated by the handler against + * `parseOptionalJsonBody`, so it is not attached to the contract. + */ +export const v1RollbackWorkflowBodySchema = z.object({ + version: deploymentVersionNumberSchema.optional(), +}) + +export type V1RollbackWorkflowBody = z.input + +const v1DeploymentStateSchema = z.object({ + id: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + warnings: z.array(z.string()), +}) + +export const v1DeployWorkflowDataSchema = v1DeploymentStateSchema.extend({ + version: z.number().optional(), +}) + +export type V1DeployWorkflowData = z.output + +export const v1RollbackWorkflowDataSchema = v1DeploymentStateSchema.extend({ + version: z.number(), +}) + +export type V1RollbackWorkflowData = z.output + +export type V1UndeployWorkflowData = z.output + +const withV1Limits = (data: T) => + z.object({ + data, + limits: z.unknown().optional(), + }) + +export const v1DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v1/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: withV1Limits(v1DeployWorkflowDataSchema), + }, +}) + +export const v1UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v1/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: withV1Limits(v1DeploymentStateSchema), + }, +}) + +export const v1RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v1/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: withV1Limits(v1RollbackWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 75cd5cbce8d..6d5799de4ae 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -152,6 +152,50 @@ export async function parseJsonBody( } } +/** + * Reads an entirely optional JSON body with the standard byte cap. An absent + * or empty body resolves to `{ success: true, data: undefined }`; malformed + * JSON and oversized payloads return the standard 400/413 error responses. + * Use for endpoints whose body may be omitted altogether (e.g. optional + * metadata on a deploy call) — `parseJsonBody` rejects empty bodies. + */ +export async function parseOptionalJsonBody( + request: Request, + maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES +): Promise< + { success: true; data: unknown } | { success: false; response: NextResponse<{ error: string }> } +> { + try { + assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL) + + const stream = request.body + const text = stream + ? new TextDecoder().decode( + await readStreamToBufferWithLimit(stream, { maxBytes, label: REQUEST_BODY_LABEL }) + ) + : await request.text() + + if (!text.trim()) { + return { success: true, data: undefined } + } + return { success: true, data: JSON.parse(text) } + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return { + success: false, + response: NextResponse.json( + { error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` }, + { status: 413 } + ), + } + } + return { + success: false, + response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }), + } + } +} + export function searchParamsToObject( searchParams: URLSearchParams ): Record { diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index b2e5b4ec45c..d930858fb6a 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -22,7 +22,10 @@ import { undeployWorkflow, } from '@/lib/workflows/persistence/utils' import { validateWorkflowSchedules } from '@/lib/workflows/schedules' -import { emitWorkflowDeployedEvent } from '@/lib/workspace-events/emitter' +import { + emitWorkflowDeployedEvent, + emitWorkflowUndeployedEvent, +} from '@/lib/workspace-events/emitter' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('DeployOrchestration') @@ -288,6 +291,15 @@ export async function performFullUndeploy( await notifySocketDeploymentChanged(workflowId) const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId) + const undeployWorkspaceId = workflowData.workspaceId as string | null + if (undeployWorkspaceId) { + void emitWorkflowUndeployedEvent({ + workflowId, + workflowName: (workflowData.name as string) || workflowId, + workspaceId: undeployWorkspaceId, + }) + } + return { success: true, warnings: sideEffectWarning ? [sideEffectWarning] : undefined } } diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 25b77d380d1..28c9ae95806 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -12,7 +12,7 @@ import { saveWorkflowToNormalizedTables as saveWorkflowToNormalizedTablesRaw } f import type { DbOrTx, NormalizedWorkflowData } from '@sim/workflow-persistence/types' import type { BlockState, Loop, Parallel, WorkflowState } from '@sim/workflow-types/workflow' import type { InferSelectModel } from 'drizzle-orm' -import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, lt, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { @@ -1143,11 +1143,55 @@ async function activateWorkflowVersionById(params: { } } +/** + * Resolves the deployment version that precedes the currently active one — + * the default rollback target when no explicit version is given. + */ +export async function findPreviousDeploymentVersion( + workflowId: string +): Promise< + { ok: true; version: number } | { ok: false; reason: 'no_active_version' | 'no_previous_version' } +> { + const [activeRow] = await db + .select({ version: workflowDeploymentVersion.version }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .limit(1) + + if (!activeRow) { + return { ok: false, reason: 'no_active_version' } + } + + const [previousRow] = await db + .select({ version: workflowDeploymentVersion.version }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + lt(workflowDeploymentVersion.version, activeRow.version) + ) + ) + .orderBy(desc(workflowDeploymentVersion.version)) + .limit(1) + + if (!previousRow) { + return { ok: false, reason: 'no_previous_version' } + } + + return { ok: true, version: previousRow.version } +} + export async function listWorkflowVersions(workflowId: string): Promise<{ versions: Array<{ id: string version: number name: string | null + description: string | null isActive: boolean createdAt: Date createdBy: string | null @@ -1156,11 +1200,12 @@ export async function listWorkflowVersions(workflowId: string): Promise<{ }> { const { user } = await import('@sim/db') - const versions = await db + const rows = await db .select({ id: workflowDeploymentVersion.id, version: workflowDeploymentVersion.version, name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, isActive: workflowDeploymentVersion.isActive, createdAt: workflowDeploymentVersion.createdAt, createdBy: workflowDeploymentVersion.createdBy, @@ -1171,5 +1216,10 @@ export async function listWorkflowVersions(workflowId: string): Promise<{ .where(eq(workflowDeploymentVersion.workflowId, workflowId)) .orderBy(desc(workflowDeploymentVersion.version)) - return { versions } + return { + versions: rows.map((row) => ({ + ...row, + deployedByName: row.deployedByName ?? (row.createdBy === 'admin-api' ? 'Admin' : null), + })), + } } diff --git a/apps/sim/lib/workspace-events/constants.ts b/apps/sim/lib/workspace-events/constants.ts index 5a5c0f09b8e..12f855dc7f0 100644 --- a/apps/sim/lib/workspace-events/constants.ts +++ b/apps/sim/lib/workspace-events/constants.ts @@ -17,6 +17,7 @@ export const SIM_PLAIN_EVENT_TYPES = [ 'execution_success', 'execution_error', 'workflow_deployed', + 'workflow_undeployed', ] as const /** Rule-based events ported from the legacy notification alert rules. */ diff --git a/apps/sim/lib/workspace-events/emitter.ts b/apps/sim/lib/workspace-events/emitter.ts index 07e3ddaa2f2..4c9aa07e440 100644 --- a/apps/sim/lib/workspace-events/emitter.ts +++ b/apps/sim/lib/workspace-events/emitter.ts @@ -7,7 +7,11 @@ import { SIM_RULE_COOLDOWN_HOURS, SIM_TRIGGER_PROVIDER, } from '@/lib/workspace-events/constants' -import { buildDeployEventPayload, buildExecutionEventPayload } from '@/lib/workspace-events/payload' +import { + buildDeployEventPayload, + buildExecutionEventPayload, + buildUndeployEventPayload, +} from '@/lib/workspace-events/payload' import { evaluateRule } from '@/lib/workspace-events/rules' import { claimCooldown, isWithinCooldown, readLastFiredAt } from '@/lib/workspace-events/state' import { @@ -158,16 +162,15 @@ export async function emitExecutionCompletedEvent(log: WorkflowExecutionLog): Pr } /** - * Emits a workflow_deployed event to subscribed side-effect workflows. - * - * Fired on any deployment activation (fresh deploy, redeploy, version - * rollback/activation). Fire-and-forget: failures never affect the deploy. + * Shared dispatch loop for workflow lifecycle events: matches subscriptions + * on event type and workflow scope, never notifying the source workflow about + * itself. Fire-and-forget: failures never affect the lifecycle operation. */ -export async function emitWorkflowDeployedEvent(params: { +async function emitWorkflowLifecycleEvent(params: { + eventType: 'workflow_deployed' | 'workflow_undeployed' workflowId: string - workflowName: string workspaceId: string - version: number | null + payload: SimEventPayload }): Promise { try { const subscriptions = await fetchSimTriggerSubscriptions(params.workspaceId) @@ -176,23 +179,63 @@ export async function emitWorkflowDeployedEvent(params: { for (const subscription of subscriptions) { const config = parseSubscriptionConfig(subscription.webhook.providerConfig) if (!config) continue - if (config.eventType !== 'workflow_deployed') continue + if (config.eventType !== params.eventType) continue if (subscription.webhook.workflowId === params.workflowId) continue if (!matchesWorkflowScope(config, params.workflowId)) continue - const payload = buildDeployEventPayload({ - workflowId: params.workflowId, - workflowName: params.workflowName, - version: params.version, - }) - - await dispatchSimEvent(subscription, payload) + await dispatchSimEvent(subscription, params.payload) } } catch (error) { - logger.error('Failed to emit workflow deployed event', { + logger.error(`Failed to emit ${params.eventType} event`, { error, workflowId: params.workflowId, }) } } + +/** + * Emits a workflow_deployed event to subscribed side-effect workflows. + * + * Fired on any deployment activation (fresh deploy, redeploy, version + * rollback/activation). Fire-and-forget: failures never affect the deploy. + */ +export async function emitWorkflowDeployedEvent(params: { + workflowId: string + workflowName: string + workspaceId: string + version: number | null +}): Promise { + await emitWorkflowLifecycleEvent({ + eventType: 'workflow_deployed', + workflowId: params.workflowId, + workspaceId: params.workspaceId, + payload: buildDeployEventPayload({ + workflowId: params.workflowId, + workflowName: params.workflowName, + version: params.version, + }), + }) +} + +/** + * Emits a workflow_undeployed event to subscribed side-effect workflows. + * + * Fired when a workflow is taken offline. Fire-and-forget: failures never + * affect the undeploy. + */ +export async function emitWorkflowUndeployedEvent(params: { + workflowId: string + workflowName: string + workspaceId: string +}): Promise { + await emitWorkflowLifecycleEvent({ + eventType: 'workflow_undeployed', + workflowId: params.workflowId, + workspaceId: params.workspaceId, + payload: buildUndeployEventPayload({ + workflowId: params.workflowId, + workflowName: params.workflowName, + }), + }) +} diff --git a/apps/sim/lib/workspace-events/payload.ts b/apps/sim/lib/workspace-events/payload.ts index 8d1082ec13f..b05d237c987 100644 --- a/apps/sim/lib/workspace-events/payload.ts +++ b/apps/sim/lib/workspace-events/payload.ts @@ -67,7 +67,7 @@ function summarizeRun(context: ExecutionEventContext): SimRunSummary { * the condition that fired, so it nests under `triggeringRun`. */ export function buildExecutionEventPayload(params: { - event: Exclude | SimRuleEventType + event: Exclude | SimRuleEventType workflowName: string context: ExecutionEventContext }): SimEventPayload { @@ -99,6 +99,18 @@ export function buildDeployEventPayload(params: { } } +/** Payload for workflow_undeployed events. */ +export function buildUndeployEventPayload(params: { + workflowId: string + workflowName: string +}): SimEventPayload { + return basePayload({ + event: 'workflow_undeployed', + workflowId: params.workflowId, + workflowName: params.workflowName, + }) +} + /** Payload for no_activity events (no source run exists). */ export function buildNoActivityEventPayload(params: { workflowId: string diff --git a/apps/sim/tools/deployments/deploy.ts b/apps/sim/tools/deployments/deploy.ts new file mode 100644 index 00000000000..f86ec99b398 --- /dev/null +++ b/apps/sim/tools/deployments/deploy.ts @@ -0,0 +1,70 @@ +import type { DeploymentsDeployParams, DeploymentsDeployResponse } from '@/tools/deployments/types' +import type { ToolConfig } from '@/tools/types' + +export const deploymentsDeployTool: ToolConfig = + { + id: 'deployments_deploy', + name: 'Deploy Workflow', + description: + 'Deploy a workflow’s current draft state, creating a new deployment version and making it live for API execution. Requires admin permission on the workflow’s workspace.', + version: '1.0.0', + + params: { + workflowId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workflow to deploy', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional label for the new deployment version', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional summary of what changed in this version', + }, + }, + + request: { + url: '/api/tools/deployments/deploy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + return { + workflowId: params.workflowId, + workspaceId, + ...(params.name ? { name: params.name } : {}), + ...(params.description ? { description: params.description } : {}), + } + }, + }, + + transformResponse: async (response) => response.json(), + + outputs: { + workflowId: { type: 'string', description: 'ID of the deployed workflow' }, + isDeployed: { type: 'boolean', description: 'Whether the workflow is now deployed' }, + deployedAt: { + type: 'string', + description: 'ISO 8601 timestamp of the deployment (null if unavailable)', + }, + version: { + type: 'number', + description: 'The deployment version that is now active', + optional: true, + }, + warnings: { + type: 'array', + description: 'Non-fatal warnings (e.g. trigger or schedule sync still in progress)', + }, + }, + } diff --git a/apps/sim/tools/deployments/get_version.ts b/apps/sim/tools/deployments/get_version.ts new file mode 100644 index 00000000000..088af15188c --- /dev/null +++ b/apps/sim/tools/deployments/get_version.ts @@ -0,0 +1,63 @@ +import type { + DeploymentsGetVersionParams, + DeploymentsGetVersionResponse, +} from '@/tools/deployments/types' +import type { ToolConfig } from '@/tools/types' + +export const deploymentsGetVersionTool: ToolConfig< + DeploymentsGetVersionParams, + DeploymentsGetVersionResponse +> = { + id: 'deployments_get_version', + name: 'Get Deployment Version', + description: + 'Fetch a single deployment version of a workflow, including its metadata and the full workflow state snapshot that was deployed.', + version: '1.0.0', + + params: { + workflowId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workflow', + }, + version: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The deployment version number to fetch', + }, + }, + + request: { + url: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + const qs = new URLSearchParams({ + workflowId: params.workflowId, + workspaceId, + version: String(params.version), + }) + return `/api/tools/deployments/version?${qs.toString()}` + }, + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + + transformResponse: async (response) => response.json(), + + outputs: { + workflowId: { type: 'string', description: 'ID of the workflow' }, + version: { type: 'number', description: 'The deployment version number' }, + name: { type: 'string', description: 'Version label', optional: true }, + description: { type: 'string', description: 'Version description', optional: true }, + isActive: { type: 'boolean', description: 'Whether this version is currently live' }, + createdAt: { type: 'string', description: 'When this version was deployed (ISO 8601)' }, + deployedState: { + type: 'json', + description: 'The full workflow state snapshot (blocks, edges, loops, parallels, variables)', + }, + }, +} diff --git a/apps/sim/tools/deployments/index.ts b/apps/sim/tools/deployments/index.ts new file mode 100644 index 00000000000..93214ff7b40 --- /dev/null +++ b/apps/sim/tools/deployments/index.ts @@ -0,0 +1,5 @@ +export { deploymentsDeployTool } from '@/tools/deployments/deploy' +export { deploymentsGetVersionTool } from '@/tools/deployments/get_version' +export { deploymentsListVersionsTool } from '@/tools/deployments/list_versions' +export { deploymentsPromoteTool } from '@/tools/deployments/promote' +export { deploymentsUndeployTool } from '@/tools/deployments/undeploy' diff --git a/apps/sim/tools/deployments/list_versions.ts b/apps/sim/tools/deployments/list_versions.ts new file mode 100644 index 00000000000..ce349bd6723 --- /dev/null +++ b/apps/sim/tools/deployments/list_versions.ts @@ -0,0 +1,49 @@ +import type { + DeploymentsListVersionsParams, + DeploymentsListVersionsResponse, +} from '@/tools/deployments/types' +import type { ToolConfig } from '@/tools/types' + +export const deploymentsListVersionsTool: ToolConfig< + DeploymentsListVersionsParams, + DeploymentsListVersionsResponse +> = { + id: 'deployments_list_versions', + name: 'List Deployment Versions', + description: + 'List every deployment version of a workflow, newest first, including which version is currently live.', + version: '1.0.0', + + params: { + workflowId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workflow', + }, + }, + + request: { + url: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + const qs = new URLSearchParams({ workflowId: params.workflowId, workspaceId }) + return `/api/tools/deployments/versions?${qs.toString()}` + }, + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + }, + + transformResponse: async (response) => response.json(), + + outputs: { + workflowId: { type: 'string', description: 'ID of the workflow' }, + versions: { + type: 'array', + description: + 'Deployment versions, newest first (id, version, name, description, isActive, createdAt, createdBy, deployedByName)', + }, + }, +} diff --git a/apps/sim/tools/deployments/promote.ts b/apps/sim/tools/deployments/promote.ts new file mode 100644 index 00000000000..9b45e9cd346 --- /dev/null +++ b/apps/sim/tools/deployments/promote.ts @@ -0,0 +1,64 @@ +import type { + DeploymentsPromoteParams, + DeploymentsPromoteResponse, +} from '@/tools/deployments/types' +import type { ToolConfig } from '@/tools/types' + +export const deploymentsPromoteTool: ToolConfig< + DeploymentsPromoteParams, + DeploymentsPromoteResponse +> = { + id: 'deployments_promote', + name: 'Promote Version to Live', + description: + 'Make a specific deployment version the live one without creating a new version — the same operation as Promote to live in the deploy modal. Useful for rolling back to a known-good version. Also works on an undeployed workflow: it re-deploys the workflow live at that version. Requires admin permission on the workflow’s workspace.', + version: '1.0.0', + + params: { + workflowId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workflow', + }, + version: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The deployment version number to promote to live', + }, + }, + + request: { + url: '/api/tools/deployments/promote', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + return { + workflowId: params.workflowId, + workspaceId, + version: Number(params.version), + } + }, + }, + + transformResponse: async (response) => response.json(), + + outputs: { + workflowId: { type: 'string', description: 'ID of the workflow' }, + isDeployed: { type: 'boolean', description: 'Whether the workflow is now deployed' }, + deployedAt: { + type: 'string', + description: 'ISO 8601 timestamp of the active deployment (null if unavailable)', + }, + version: { type: 'number', description: 'The deployment version that is now live' }, + warnings: { + type: 'array', + description: 'Non-fatal warnings (e.g. trigger or schedule sync still in progress)', + }, + }, +} diff --git a/apps/sim/tools/deployments/types.ts b/apps/sim/tools/deployments/types.ts new file mode 100644 index 00000000000..4ddff9d616a --- /dev/null +++ b/apps/sim/tools/deployments/types.ts @@ -0,0 +1,89 @@ +import type { ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +export interface DeploymentsDeployParams { + workflowId: string + name?: string + description?: string + _context?: WorkflowToolExecutionContext +} + +export interface DeploymentsUndeployParams { + workflowId: string + _context?: WorkflowToolExecutionContext +} + +export interface DeploymentsPromoteParams { + workflowId: string + version: number + _context?: WorkflowToolExecutionContext +} + +export interface DeploymentsListVersionsParams { + workflowId: string + _context?: WorkflowToolExecutionContext +} + +export interface DeploymentsGetVersionParams { + workflowId: string + version: number + _context?: WorkflowToolExecutionContext +} + +export interface DeploymentVersionSummary { + id: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: string + createdBy: string | null + deployedByName: string | null +} + +export interface DeploymentsDeployResponse extends ToolResponse { + output: { + workflowId: string + isDeployed: boolean + deployedAt: string | null + version?: number + warnings: string[] + } +} + +export interface DeploymentsUndeployResponse extends ToolResponse { + output: { + workflowId: string + isDeployed: boolean + deployedAt: null + warnings: string[] + } +} + +export interface DeploymentsPromoteResponse extends ToolResponse { + output: { + workflowId: string + isDeployed: boolean + deployedAt: string | null + version: number + warnings: string[] + } +} + +export interface DeploymentsListVersionsResponse extends ToolResponse { + output: { + workflowId: string + versions: DeploymentVersionSummary[] + } +} + +export interface DeploymentsGetVersionResponse extends ToolResponse { + output: { + workflowId: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: string + deployedState: unknown + } +} diff --git a/apps/sim/tools/deployments/undeploy.ts b/apps/sim/tools/deployments/undeploy.ts new file mode 100644 index 00000000000..40bd7110451 --- /dev/null +++ b/apps/sim/tools/deployments/undeploy.ts @@ -0,0 +1,54 @@ +import type { + DeploymentsUndeployParams, + DeploymentsUndeployResponse, +} from '@/tools/deployments/types' +import type { ToolConfig } from '@/tools/types' + +export const deploymentsUndeployTool: ToolConfig< + DeploymentsUndeployParams, + DeploymentsUndeployResponse +> = { + id: 'deployments_undeploy', + name: 'Undeploy Workflow', + description: + 'Take a deployed workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Requires admin permission on the workflow’s workspace.', + version: '1.0.0', + + params: { + workflowId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the workflow to undeploy', + }, + }, + + request: { + url: '/api/tools/deployments/undeploy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + return { workflowId: params.workflowId, workspaceId } + }, + }, + + transformResponse: async (response) => response.json(), + + outputs: { + workflowId: { type: 'string', description: 'ID of the undeployed workflow' }, + isDeployed: { type: 'boolean', description: 'Whether the workflow is still deployed (false)' }, + deployedAt: { + type: 'string', + description: 'Always null after an undeploy', + optional: true, + }, + warnings: { + type: 'array', + description: 'Non-fatal warnings (e.g. trigger or schedule cleanup still in progress)', + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3ca9537b169..18a16223e61 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -625,6 +625,13 @@ import { daytonaStopSandboxTool, daytonaUploadFileTool, } from '@/tools/daytona' +import { + deploymentsDeployTool, + deploymentsGetVersionTool, + deploymentsListVersionsTool, + deploymentsPromoteTool, + deploymentsUndeployTool, +} from '@/tools/deployments' import { devinAppendSessionTagsTool, devinArchiveSessionTool, @@ -4634,6 +4641,11 @@ export const tools: Record = { daytona_start_sandbox: daytonaStartSandboxTool, daytona_stop_sandbox: daytonaStopSandboxTool, daytona_upload_file: daytonaUploadFileTool, + deployments_deploy: deploymentsDeployTool, + deployments_get_version: deploymentsGetVersionTool, + deployments_list_versions: deploymentsListVersionsTool, + deployments_promote: deploymentsPromoteTool, + deployments_undeploy: deploymentsUndeployTool, dub_bulk_create_links: dubBulkCreateLinksTool, dub_bulk_delete_links: dubBulkDeleteLinksTool, dub_bulk_update_links: dubBulkUpdateLinksTool, diff --git a/apps/sim/triggers/sim/workspace-event.test.ts b/apps/sim/triggers/sim/workspace-event.test.ts index 9d65c95b678..e338fb4cab5 100644 --- a/apps/sim/triggers/sim/workspace-event.test.ts +++ b/apps/sim/triggers/sim/workspace-event.test.ts @@ -247,6 +247,12 @@ describe('sim workspace event outputs', () => { ) }) + it('workflow_undeployed exposes only the base fields', () => { + expect(visibleOutputsFor('workflow_undeployed')).toEqual( + ['event', 'timestamp', 'workflowId', 'workflowName'].sort() + ) + }) + it('no_activity exposes only the base fields', () => { expect(visibleOutputsFor('no_activity')).toEqual( ['event', 'timestamp', 'workflowId', 'workflowName'].sort() diff --git a/apps/sim/triggers/sim/workspace-event.ts b/apps/sim/triggers/sim/workspace-event.ts index 1f8d9d43ea1..20d4585aeae 100644 --- a/apps/sim/triggers/sim/workspace-event.ts +++ b/apps/sim/triggers/sim/workspace-event.ts @@ -26,6 +26,7 @@ export const simWorkspaceEventTrigger: TriggerConfig = { { id: 'execution_error', label: 'Execution Error', group: 'Events' }, { id: 'execution_success', label: 'Execution Success', group: 'Events' }, { id: 'workflow_deployed', label: 'Workflow Deployed', group: 'Events' }, + { id: 'workflow_undeployed', label: 'Workflow Undeployed', group: 'Events' }, { id: 'consecutive_failures', label: 'Consecutive Failures', group: 'Alert Conditions' }, { id: 'failure_rate', label: 'Failure Rate', group: 'Alert Conditions' }, { id: 'latency_threshold', label: 'Latency Threshold', group: 'Alert Conditions' }, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 572c17107b8..ed468073fe8 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 819, - zodRoutes: 819, + totalRoutes: 826, + zodRoutes: 826, nonZodRoutes: 0, } as const diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index c68b8a0b768..94929e68e39 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -47,7 +47,14 @@ const HANDWRITTEN_INTEGRATION_DOCS = new Set([ * integration page. The writer's filter and the stale-doc cleanup must both * honor this set, or cleanup deletes what the writer emits (losing manual content). */ -const NATIVE_RESOURCE_BLOCK_TYPES = new Set(['memory', 'knowledge', 'table', 'enrichment', 'logs']) +const NATIVE_RESOURCE_BLOCK_TYPES = new Set([ + 'memory', + 'knowledge', + 'table', + 'enrichment', + 'logs', + 'deployments', +]) /** Trigger doc pages that are hand-written and must never be overwritten. */ const HANDWRITTEN_TRIGGER_DOCS = new Set([