From 890e9f4d2a5820e406fd2f9bbc497f7bbe8b02e4 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 15:08:18 -0700 Subject: [PATCH 1/6] feat(deployments): add v1 deployment endpoints and Deployments block --- apps/docs/components/icons.tsx | 23 ++ .../(generated)/workflows/meta.json | 3 + .../docs/en/integrations/deployments.mdx | 123 +++++++ .../content/docs/en/integrations/meta.json | 1 + .../docs/en/workflows/deployment/api.mdx | 22 ++ apps/docs/openapi.json | 344 ++++++++++++++++++ .../app/api/tools/deployments/deploy/route.ts | 83 +++++ .../api/tools/deployments/promote/route.ts | 83 +++++ .../app/api/tools/deployments/routes.test.ts | 323 ++++++++++++++++ .../api/tools/deployments/undeploy/route.ts | 80 ++++ apps/sim/app/api/tools/deployments/utils.ts | 71 ++++ .../api/tools/deployments/version/route.ts | 83 +++++ .../api/tools/deployments/versions/route.ts | 52 +++ apps/sim/app/api/v1/middleware.ts | 11 +- .../v1/workflows/[id]/deploy/route.test.ts | 248 +++++++++++++ .../app/api/v1/workflows/[id]/deploy/route.ts | 207 +++++++++++ .../v1/workflows/[id]/rollback/route.test.ts | 181 +++++++++ .../api/v1/workflows/[id]/rollback/route.ts | 141 +++++++ apps/sim/blocks/blocks/deployments.ts | 202 ++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 23 ++ .../lib/api/contracts/tools/deployments.ts | 102 ++++++ apps/sim/lib/api/contracts/tools/index.ts | 1 + apps/sim/lib/api/contracts/v1/workflows.ts | 82 +++++ apps/sim/lib/workflows/persistence/utils.ts | 45 ++- apps/sim/tools/deployments/deploy.ts | 56 +++ apps/sim/tools/deployments/get_version.ts | 53 +++ apps/sim/tools/deployments/index.ts | 5 + apps/sim/tools/deployments/list_versions.ts | 43 +++ apps/sim/tools/deployments/promote.ts | 54 +++ apps/sim/tools/deployments/types.ts | 83 +++++ apps/sim/tools/deployments/undeploy.ts | 48 +++ apps/sim/tools/registry.ts | 12 + scripts/generate-docs.ts | 9 +- 34 files changed, 2892 insertions(+), 7 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/deployments.mdx create mode 100644 apps/sim/app/api/tools/deployments/deploy/route.ts create mode 100644 apps/sim/app/api/tools/deployments/promote/route.ts create mode 100644 apps/sim/app/api/tools/deployments/routes.test.ts create mode 100644 apps/sim/app/api/tools/deployments/undeploy/route.ts create mode 100644 apps/sim/app/api/tools/deployments/utils.ts create mode 100644 apps/sim/app/api/tools/deployments/version/route.ts create mode 100644 apps/sim/app/api/tools/deployments/versions/route.ts create mode 100644 apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts create mode 100644 apps/sim/app/api/v1/workflows/[id]/deploy/route.ts create mode 100644 apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts create mode 100644 apps/sim/app/api/v1/workflows/[id]/rollback/route.ts create mode 100644 apps/sim/blocks/blocks/deployments.ts create mode 100644 apps/sim/lib/api/contracts/tools/deployments.ts create mode 100644 apps/sim/tools/deployments/deploy.ts create mode 100644 apps/sim/tools/deployments/get_version.ts create mode 100644 apps/sim/tools/deployments/index.ts create mode 100644 apps/sim/tools/deployments/list_versions.ts create mode 100644 apps/sim/tools/deployments/promote.ts create mode 100644 apps/sim/tools/deployments/types.ts create mode 100644 apps/sim/tools/deployments/undeploy.ts 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 | +| `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. 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 | +| `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, 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..15ade28711d 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: diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 467626104a0..4470573578f 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -1104,6 +1104,315 @@ } } }, + "/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.", + "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": "wf_1a2b3c4d5e" + } + } + ], + "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" + } + } + } + } + } + }, + "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": "wf_abc123", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00Z", + "version": 4 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "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.", + "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": "wf_1a2b3c4d5e" + } + } + ], + "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": "wf_abc123", + "isDeployed": false, + "deployedAt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "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. 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.", + "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": "wf_1a2b3c4d5e" + } + } + ], + "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 + } + } + } + } + } + }, + "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": "wf_abc123", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00Z", + "version": 3 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "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", @@ -5524,6 +5833,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": "wf_1a2b3c4d5e" + }, + "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.", 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..214c9e881b2 --- /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, name, description } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, '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, + 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..59a0b2d0c18 --- /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, version } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, '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..19bfe2366fc --- /dev/null +++ b/apps/sim/app/api/tools/deployments/routes.test.ts @@ -0,0 +1,323 @@ +/** + * @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 { + mockEnforceUserOrIpRateLimit, + mockPerformFullDeploy, + mockPerformFullUndeploy, + mockPerformActivateVersion, + mockListWorkflowVersions, +} = vi.hoisted(() => ({ + mockEnforceUserOrIpRateLimit: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), + mockPerformActivateVersion: vi.fn(), + mockListWorkflowVersions: vi.fn(), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceUserOrIpRateLimit: mockEnforceUserOrIpRateLimit, +})) + +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', + }) + mockEnforceUserOrIpRateLimit.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 })) + + 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 })) + + 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, + 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 })) + + expect(response.status).toBe(423) + 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 })) + + expect(response.status).toBe(400) + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) + + it('undeploys a deployed workflow', async () => { + const response = await undeployPost(makePost('undeploy', { workflowId: WORKFLOW_ID })) + + 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, 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 })) + + 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, 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, + 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}`)) + + 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}&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}&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..ae22e0014b7 --- /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 } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, '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..ec639f7d586 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/utils.ts @@ -0,0 +1,71 @@ +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 { enforceUserOrIpRateLimit } 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 enforceUserOrIpRateLimit('deployment-tools', auth.userId, request) + 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. Deployment mutations require `admin`, reads require `read`, + * matching the UI deploy routes. + */ +export async function authorizeDeploymentWorkflow( + userId: string, + workflowId: 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), + } + } + + 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..b52c9fdc464 --- /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, version } = parsed.data.query + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, '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..fe4a3b2ffe7 --- /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 } = parsed.data.query + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, '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/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 6472cbb1f76..8279549dd2f 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,8 @@ export async function checkWorkspaceScope( return null } +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 +202,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..43d51650c6e --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -0,0 +1,248 @@ +/** + * @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 { 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('requires admin workspace permission', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(403) + expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ allowed: true }), + 'user-1', + 'ws-1', + 'admin' + ) + 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, + }) + }) + + 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 }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'workflow_undeployed', + expect.objectContaining({ workflow_id: WORKFLOW_ID }), + expect.anything() + ) + }) + + it('requires admin workspace permission', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await DELETE(makeRequest('DELETE'), makeContext()) + + expect(response.status).toBe(403) + 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..c22829e22e1 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -0,0 +1,207 @@ +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 { 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 + + // boundary-raw-json: the deploy body is optional version metadata; tolerate an absent or empty body + const rawBody = await request.json().catch(() => ({})) + const body = v1DeployWorkflowBodySchema.safeParse(rawBody ?? {}) + if (!body.success) { + return validationErrorResponse(body.error) + } + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId!, + 'admin' + ) + if (accessError) return accessError + + 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, + 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: workflowData.workspaceId ?? '' }, + { + groups: workflowData.workspaceId ? { workspace: workflowData.workspaceId } : undefined, + 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) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId!, + 'admin' + ) + if (accessError) return accessError + + 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: workflowData.workspaceId ?? '' }, + workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + ) + + 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..874feb6541e --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -0,0 +1,181 @@ +/** + * @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 { 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('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('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, + }) + }) + + 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('requires admin workspace permission', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(403) + 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..a58d735362e --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -0,0 +1,141 @@ +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 { 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 + + // boundary-raw-json: the rollback body is an optional target version; tolerate an absent or empty body + const rawBody = await request.json().catch(() => ({})) + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody ?? {}) + if (!body.success) { + return validationErrorResponse(body.error) + } + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId!, + 'admin' + ) + if (accessError) return accessError + + 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: workflowData.workspaceId ?? '', version: targetVersion }, + workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + ) + + 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..9760e21cdc2 --- /dev/null +++ b/apps/sim/blocks/blocks/deployments.ts @@ -0,0 +1,202 @@ +import { SimDeploymentsIcon } from '@/components/icons' +import { fetchWorkspaceWorkflowOptions } from '@/lib/workflows/subblocks/options' +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. + - 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: 'dropdown', + options: [], + placeholder: 'Select workflow', + mode: 'basic', + canonicalParamId: 'workflowId', + required: true, + fetchOptions: () => fetchWorkspaceWorkflowOptions(), + }, + { + 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 || undefined, + description: params.versionDescription || 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, 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, +}) + +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, + 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, +}) + +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, + 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..b84271196d6 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -51,3 +51,85 @@ export const v1GetWorkflowContract = defineRouteContract({ schema: v1WorkflowApiResponseWithLimitsSchema, }, }) + +/** + * Optional version metadata accepted by the v1 deploy endpoint. The route + * tolerates an absent/empty body, so this schema is validated by the handler + * against a tolerant raw-JSON read instead of being attached to the contract. + */ +export const v1DeployWorkflowBodySchema = z.object({ + name: z + .string() + .trim() + .min(1, 'name cannot be empty') + .max(100, 'name must be 100 characters or less') + .optional(), + description: z + .string() + .trim() + .max(2000, 'description must be 2000 characters or less') + .optional(), +}) + +export type V1DeployWorkflowBody = z.input + +/** + * 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 a + * tolerant raw-JSON read, so it is not attached to the contract. + */ +export const v1RollbackWorkflowBodySchema = z.object({ + version: z + .number() + .int('version must be an integer') + .min(1, 'version must be a positive integer') + .optional(), +}) + +export type V1RollbackWorkflowBody = z.input + +export const v1WorkflowDeploymentDataSchema = z.object({ + id: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + version: z.number().optional(), + warnings: z.array(z.string()).optional(), +}) + +export type V1WorkflowDeploymentData = z.output + +const v1WorkflowDeploymentResponseSchema = z.object({ + data: v1WorkflowDeploymentDataSchema, + limits: z.unknown().optional(), +}) + +export const v1DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v1/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v1WorkflowDeploymentResponseSchema, + }, +}) + +export const v1UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v1/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v1WorkflowDeploymentResponseSchema, + }, +}) + +export const v1RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v1/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v1WorkflowDeploymentResponseSchema, + }, +}) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 25b77d380d1..7a61943b5cb 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,6 +1143,49 @@ 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 diff --git a/apps/sim/tools/deployments/deploy.ts b/apps/sim/tools/deployments/deploy.ts new file mode 100644 index 00000000000..667b88fc374 --- /dev/null +++ b/apps/sim/tools/deployments/deploy.ts @@ -0,0 +1,56 @@ +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) => ({ + workflowId: params.workflowId, + ...(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' }, + version: { type: 'number', description: 'The deployment version that is now active' }, + 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..11c315e1343 --- /dev/null +++ b/apps/sim/tools/deployments/get_version.ts @@ -0,0 +1,53 @@ +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) => + `/api/tools/deployments/version?workflowId=${encodeURIComponent(params.workflowId)}&version=${params.version}`, + 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..a103b48c219 --- /dev/null +++ b/apps/sim/tools/deployments/list_versions.ts @@ -0,0 +1,43 @@ +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) => + `/api/tools/deployments/versions?workflowId=${encodeURIComponent(params.workflowId)}`, + 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, 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..2d4ff77cc1c --- /dev/null +++ b/apps/sim/tools/deployments/promote.ts @@ -0,0 +1,54 @@ +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. 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) => ({ + workflowId: params.workflowId, + version: 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' }, + 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..82358357796 --- /dev/null +++ b/apps/sim/tools/deployments/types.ts @@ -0,0 +1,83 @@ +import type { ToolResponse } from '@/tools/types' + +export interface DeploymentsDeployParams { + workflowId: string + name?: string + description?: string +} + +export interface DeploymentsUndeployParams { + workflowId: string +} + +export interface DeploymentsPromoteParams { + workflowId: string + version: number +} + +export interface DeploymentsListVersionsParams { + workflowId: string +} + +export interface DeploymentsGetVersionParams { + workflowId: string + version: number +} + +export interface DeploymentVersionSummary { + id: string + version: number + name: 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..cd87fde7e4d --- /dev/null +++ b/apps/sim/tools/deployments/undeploy.ts @@ -0,0 +1,48 @@ +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) => ({ workflowId: params.workflowId }), + }, + + 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/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([ From 13e87c5f9dae7de220c34d71d1822416d2cb45c1 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 15:18:43 -0700 Subject: [PATCH 2/6] fix(deployments): require deployed workflow for rollback, normalize warnings, guard orphaned workspaceId --- apps/docs/openapi.json | 2 +- .../api/v1/workflows/[id]/deploy/route.test.ts | 8 +++++++- .../sim/app/api/v1/workflows/[id]/deploy/route.ts | 12 ++++++------ .../api/v1/workflows/[id]/rollback/route.test.ts | 15 +++++++++++++++ .../app/api/v1/workflows/[id]/rollback/route.ts | 10 +++++++--- apps/sim/lib/api/contracts/v1/workflows.ts | 2 +- 6 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 4470573578f..4a582f75856 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -1309,7 +1309,7 @@ "post": { "operationId": "rollbackWorkflow", "summary": "Rollback Workflow", - "description": "Roll the live deployment back to a previous deployment version. 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.", + "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.", "tags": ["Workflows"], "x-codeSamples": [ { diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index 43d51650c6e..4727295c2f9 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -145,6 +145,7 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { isDeployed: true, deployedAt: '2026-06-12T00:00:00.000Z', version: 4, + warnings: [], }) }) @@ -226,7 +227,12 @@ describe('DELETE /api/v1/workflows/[id]/deploy', () => { ) const body = await response.json() - expect(body.data).toEqual({ id: WORKFLOW_ID, isDeployed: false, deployedAt: null }) + expect(body.data).toEqual({ + id: WORKFLOW_ID, + isDeployed: false, + deployedAt: null, + warnings: [], + }) expect(mockCaptureServerEvent).toHaveBeenCalledWith( 'user-1', 'workflow_undeployed', diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index c22829e22e1..36ef43510f6 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -56,14 +56,14 @@ export const POST = withRouteHandler( } const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData) { + if (!workflowData?.workspaceId) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const accessError = await validateWorkspaceAccess( rateLimit, userId, - workflowData.workspaceId!, + workflowData.workspaceId, 'admin' ) if (accessError) return accessError @@ -106,7 +106,7 @@ export const POST = withRouteHandler( isDeployed: true, deployedAt: result.deployedAt?.toISOString() ?? null, version: result.version, - warnings: result.warnings, + warnings: result.warnings ?? [], }, }, limits, @@ -145,14 +145,14 @@ export const DELETE = withRouteHandler( const { id } = parsed.data.params const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData) { + if (!workflowData?.workspaceId) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const accessError = await validateWorkspaceAccess( rateLimit, userId, - workflowData.workspaceId!, + workflowData.workspaceId, 'admin' ) if (accessError) return accessError @@ -187,7 +187,7 @@ export const DELETE = withRouteHandler( id, isDeployed: false, deployedAt: null, - warnings: result.warnings, + warnings: result.warnings ?? [], }, }, limits, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index 874feb6541e..fe9496a7557 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -107,9 +107,24 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { 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()) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index a58d735362e..59b745b3475 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -56,18 +56,22 @@ export const POST = withRouteHandler( } const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData) { + if (!workflowData?.workspaceId) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const accessError = await validateWorkspaceAccess( rateLimit, userId, - workflowData.workspaceId!, + workflowData.workspaceId, 'admin' ) if (accessError) return accessError + if (!workflowData.isDeployed) { + return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 }) + } + await assertWorkflowMutable(id) let targetVersion = body.data.version @@ -121,7 +125,7 @@ export const POST = withRouteHandler( isDeployed: true, deployedAt: result.deployedAt?.toISOString() ?? null, version: targetVersion, - warnings: result.warnings, + warnings: result.warnings ?? [], }, }, limits, diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index b84271196d6..ee46fe5d2ba 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -94,7 +94,7 @@ export const v1WorkflowDeploymentDataSchema = z.object({ isDeployed: z.boolean(), deployedAt: z.string().nullable(), version: z.number().optional(), - warnings: z.array(z.string()).optional(), + warnings: z.array(z.string()), }) export type V1WorkflowDeploymentData = z.output From 8407536a36a6e026fc792178c30111cae6779e0e Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 15:43:30 -0700 Subject: [PATCH 3/6] fix(deployments): workspace-bound tool routes, optional-body parsing, version bounds, and 404 masking - Tool routes now require the executing workspace ID and reject cross-workspace targets - v1 deploy/rollback read optional bodies via parseOptionalJsonBody (size-capped, 400 on malformed JSON) - Version numbers bounded to the Postgres integer range - v1 mutation routes mask access failures as 404, matching the v1 detail route - listWorkflowVersions returns description and normalizes admin-api deployedByName (parity with mothership get_deployment_log) - Workflow selector no longer auto-selects the first workflow (new autoSelectFirstOption opt-out) - Shared deployment version metadata field schemas across UI/v1/tool contracts --- .../docs/en/integrations/deployments.mdx | 8 +-- apps/docs/openapi.json | 27 +++---- .../app/api/tools/deployments/deploy/route.ts | 6 +- .../api/tools/deployments/promote/route.ts | 4 +- .../app/api/tools/deployments/routes.test.ts | 70 +++++++++++++++---- .../api/tools/deployments/undeploy/route.ts | 4 +- apps/sim/app/api/tools/deployments/utils.ts | 18 +++-- .../api/tools/deployments/version/route.ts | 4 +- .../api/tools/deployments/versions/route.ts | 4 +- .../v1/admin/workflows/[id]/versions/route.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 1 + .../v1/workflows/[id]/deploy/route.test.ts | 26 +++++-- .../app/api/v1/workflows/[id]/deploy/route.ts | 42 +++++------ .../v1/workflows/[id]/rollback/route.test.ts | 23 +++++- .../api/v1/workflows/[id]/rollback/route.ts | 24 +++---- .../components/dropdown/dropdown.tsx | 7 +- .../editor/components/sub-block/sub-block.tsx | 1 + apps/sim/blocks/blocks/deployments.ts | 9 +-- apps/sim/blocks/types.ts | 5 ++ .../lib/api/contracts/tools/deployments.ts | 23 +++--- apps/sim/lib/api/contracts/v1/workflows.ts | 69 ++++++++++-------- apps/sim/lib/api/server/validation.ts | 44 ++++++++++++ apps/sim/lib/workflows/persistence/utils.ts | 11 ++- apps/sim/tools/deployments/deploy.ts | 28 ++++++-- apps/sim/tools/deployments/get_version.ts | 14 +++- apps/sim/tools/deployments/list_versions.ts | 12 +++- apps/sim/tools/deployments/promote.ts | 22 ++++-- apps/sim/tools/deployments/types.ts | 8 ++- apps/sim/tools/deployments/undeploy.ts | 8 ++- 29 files changed, 360 insertions(+), 164 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/deployments.mdx b/apps/docs/content/docs/en/integrations/deployments.mdx index 901de077faf..17251cc0269 100644 --- a/apps/docs/content/docs/en/integrations/deployments.mdx +++ b/apps/docs/content/docs/en/integrations/deployments.mdx @@ -36,7 +36,7 @@ Deploy a workflow’s current draft state, creating a new deployment version and | --------- | ---- | ----------- | | `workflowId` | string | ID of the deployed workflow | | `isDeployed` | boolean | Whether the workflow is now deployed | -| `deployedAt` | string | ISO 8601 timestamp of the deployment | +| `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\) | @@ -61,7 +61,7 @@ Take a deployed workflow offline. API execution stops and schedules, webhooks, a ### `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. Requires admin permission on the workflow’s workspace. +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 @@ -76,7 +76,7 @@ Make a specific deployment version the live one without creating a new version | --------- | ---- | ----------- | | `workflowId` | string | ID of the workflow | | `isDeployed` | boolean | Whether the workflow is now deployed | -| `deployedAt` | string | ISO 8601 timestamp of the active deployment | +| `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\) | @@ -95,7 +95,7 @@ List every deployment version of a workflow, newest first, including which versi | Parameter | Type | Description | | --------- | ---- | ----------- | | `workflowId` | string | ID of the workflow | -| `versions` | array | Deployment versions, newest first \(id, version, name, isActive, createdAt, createdBy, deployedByName\) | +| `versions` | array | Deployment versions, newest first \(id, version, name, description, isActive, createdAt, createdBy, deployedByName\) | ### `deployments_get_version` diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 4a582f75856..19fb5a4e00c 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -1108,7 +1108,7 @@ "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.", + "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": [ { @@ -1179,7 +1179,8 @@ "id": "wf_abc123", "isDeployed": true, "deployedAt": "2026-06-12T10:30:00Z", - "version": 4 + "version": 4, + "warnings": [] } } } @@ -1191,9 +1192,6 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, "404": { "$ref": "#/components/responses/NotFound" }, @@ -1221,7 +1219,7 @@ "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.", + "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": [ { @@ -1265,7 +1263,8 @@ "data": { "id": "wf_abc123", "isDeployed": false, - "deployedAt": null + "deployedAt": null, + "warnings": [] } } } @@ -1277,9 +1276,6 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, "404": { "$ref": "#/components/responses/NotFound" }, @@ -1309,7 +1305,7 @@ "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.", + "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": [ { @@ -1343,7 +1339,8 @@ "type": "integer", "minimum": 1, "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", - "example": 3 + "example": 3, + "maximum": 2147483647 } } } @@ -1373,7 +1370,8 @@ "id": "wf_abc123", "isDeployed": true, "deployedAt": "2026-06-12T10:30:00Z", - "version": 3 + "version": 3, + "warnings": [] } } } @@ -1385,9 +1383,6 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, "404": { "$ref": "#/components/responses/NotFound" }, diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts index 214c9e881b2..74d87926bc4 100644 --- a/apps/sim/app/api/tools/deployments/deploy/route.ts +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -36,9 +36,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workflowId, name, description } = parsed.data.body + const { workflowId, workspaceId, name, description } = parsed.data.body - const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, 'admin') + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') if (!access.ok) return access.response await assertWorkflowMutable(workflowId) @@ -52,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: auth.userId, workflowName: access.workflow.name || undefined, versionName: name, - versionDescription: description, + versionDescription: description ?? undefined, requestId, request, }) diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts index 59a0b2d0c18..e433f730b55 100644 --- a/apps/sim/app/api/tools/deployments/promote/route.ts +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -36,9 +36,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workflowId, version } = parsed.data.body + const { workflowId, workspaceId, version } = parsed.data.body - const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, 'admin') + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') if (!access.ok) return access.response await assertWorkflowMutable(workflowId) diff --git a/apps/sim/app/api/tools/deployments/routes.test.ts b/apps/sim/app/api/tools/deployments/routes.test.ts index 19bfe2366fc..bf75b419fcd 100644 --- a/apps/sim/app/api/tools/deployments/routes.test.ts +++ b/apps/sim/app/api/tools/deployments/routes.test.ts @@ -11,13 +11,13 @@ import { WorkflowLockedError } from '@sim/workflow-authz' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockEnforceUserOrIpRateLimit, + mockEnforceUserRateLimit, mockPerformFullDeploy, mockPerformFullUndeploy, mockPerformActivateVersion, mockListWorkflowVersions, } = vi.hoisted(() => ({ - mockEnforceUserOrIpRateLimit: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), mockPerformFullDeploy: vi.fn(), mockPerformFullUndeploy: vi.fn(), mockPerformActivateVersion: vi.fn(), @@ -25,7 +25,7 @@ const { })) vi.mock('@/lib/core/rate-limiter', () => ({ - enforceUserOrIpRateLimit: mockEnforceUserOrIpRateLimit, + enforceUserRateLimit: mockEnforceUserRateLimit, })) vi.mock('@/lib/workflows/orchestration', () => ({ @@ -76,7 +76,7 @@ beforeEach(() => { userId: 'user-1', authType: 'internal_jwt', }) - mockEnforceUserOrIpRateLimit.mockResolvedValue(null) + mockEnforceUserRateLimit.mockResolvedValue(null) workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue(authorized()) workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) }) @@ -96,7 +96,9 @@ describe('POST /api/tools/deployments/deploy', () => { error: 'Unauthorized', }) - const response = await deployPost(makePost('deploy', { workflowId: WORKFLOW_ID })) + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) expect(response.status).toBe(401) expect(mockPerformFullDeploy).not.toHaveBeenCalled() @@ -111,7 +113,9 @@ describe('POST /api/tools/deployments/deploy', () => { workspacePermission: 'write', }) - const response = await deployPost(makePost('deploy', { workflowId: WORKFLOW_ID })) + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) expect(response.status).toBe(403) expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ @@ -126,6 +130,7 @@ describe('POST /api/tools/deployments/deploy', () => { const response = await deployPost( makePost('deploy', { workflowId: WORKFLOW_ID, + workspaceId: 'ws-1', name: 'Release 4', description: 'Fixes the agent prompt', }) @@ -157,11 +162,31 @@ describe('POST /api/tools/deployments/deploy', () => { it('returns 423 when the workflow is locked', async () => { workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError()) - const response = await deployPost(makePost('deploy', { workflowId: WORKFLOW_ID })) + 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', () => { @@ -175,14 +200,18 @@ describe('POST /api/tools/deployments/undeploy', () => { workflow: { ...WORKFLOW_RECORD, isDeployed: false }, }) - const response = await undeployPost(makePost('undeploy', { workflowId: WORKFLOW_ID })) + 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 })) + const response = await undeployPost( + makePost('undeploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) expect(response.status).toBe(200) expect(mockPerformFullUndeploy).toHaveBeenCalledWith( @@ -208,7 +237,9 @@ describe('POST /api/tools/deployments/promote', () => { }) it('promotes the given version to live', async () => { - const response = await promotePost(makePost('promote', { workflowId: WORKFLOW_ID, version: 3 })) + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 3 }) + ) expect(response.status).toBe(200) expect(mockPerformActivateVersion).toHaveBeenCalledWith( @@ -226,7 +257,9 @@ describe('POST /api/tools/deployments/promote', () => { }) it('rejects a missing version', async () => { - const response = await promotePost(makePost('promote', { workflowId: WORKFLOW_ID })) + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) expect(response.status).toBe(400) expect(mockPerformActivateVersion).not.toHaveBeenCalled() @@ -240,7 +273,7 @@ describe('POST /api/tools/deployments/promote', () => { }) const response = await promotePost( - makePost('promote', { workflowId: WORKFLOW_ID, version: 99 }) + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 99 }) ) expect(response.status).toBe(404) @@ -254,6 +287,7 @@ describe('GET /api/tools/deployments/versions', () => { id: 'v-2', version: 2, name: null, + description: null, isActive: true, createdAt: '2026-06-12T00:00:00.000Z', createdBy: 'user-1', @@ -262,7 +296,9 @@ describe('GET /api/tools/deployments/versions', () => { ] mockListWorkflowVersions.mockResolvedValue({ versions }) - const response = await listVersionsGet(makeGet('versions', `workflowId=${WORKFLOW_ID}`)) + const response = await listVersionsGet( + makeGet('versions', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1`) + ) expect(response.status).toBe(200) expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ @@ -298,7 +334,9 @@ describe('GET /api/tools/deployments/version', () => { }, ]) - const response = await getVersionGet(makeGet('version', `workflowId=${WORKFLOW_ID}&version=3`)) + const response = await getVersionGet( + makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=3`) + ) expect(response.status).toBe(200) const body = await response.json() @@ -316,7 +354,9 @@ describe('GET /api/tools/deployments/version', () => { it('returns 404 when the version does not exist', async () => { mockVersionRow([]) - const response = await getVersionGet(makeGet('version', `workflowId=${WORKFLOW_ID}&version=9`)) + 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 index ae22e0014b7..631c02a50d7 100644 --- a/apps/sim/app/api/tools/deployments/undeploy/route.ts +++ b/apps/sim/app/api/tools/deployments/undeploy/route.ts @@ -36,9 +36,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workflowId } = parsed.data.body + const { workflowId, workspaceId } = parsed.data.body - const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, 'admin') + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') if (!access.ok) return access.response if (!access.workflow.isDeployed) { diff --git a/apps/sim/app/api/tools/deployments/utils.ts b/apps/sim/app/api/tools/deployments/utils.ts index ec639f7d586..b82b4fcdf88 100644 --- a/apps/sim/app/api/tools/deployments/utils.ts +++ b/apps/sim/app/api/tools/deployments/utils.ts @@ -5,7 +5,7 @@ import { } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter' +import { enforceUserRateLimit } from '@/lib/core/rate-limiter' const logger = createLogger('DeploymentToolsAPI') @@ -36,7 +36,7 @@ export async function authenticateDeploymentToolRequest( } } - const rateLimited = await enforceUserOrIpRateLimit('deployment-tools', auth.userId, request) + const rateLimited = await enforceUserRateLimit('deployment-tools', auth.userId) if (rateLimited) return { ok: false, response: rateLimited } return { ok: true, userId: auth.userId } @@ -44,12 +44,15 @@ export async function authenticateDeploymentToolRequest( /** * Verifies the user holds the required workspace permission on the target - * workflow. Deployment mutations require `admin`, reads require `read`, - * matching the UI deploy routes. + * 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 } @@ -67,5 +70,12 @@ export async function authorizeDeploymentWorkflow( } } + 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 index b52c9fdc464..21dd85e92a4 100644 --- a/apps/sim/app/api/tools/deployments/version/route.ts +++ b/apps/sim/app/api/tools/deployments/version/route.ts @@ -36,9 +36,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workflowId, version } = parsed.data.query + const { workflowId, workspaceId, version } = parsed.data.query - const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, 'read') + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read') if (!access.ok) return access.response const [row] = await db diff --git a/apps/sim/app/api/tools/deployments/versions/route.ts b/apps/sim/app/api/tools/deployments/versions/route.ts index fe4a3b2ffe7..42abd9eb9ea 100644 --- a/apps/sim/app/api/tools/deployments/versions/route.ts +++ b/apps/sim/app/api/tools/deployments/versions/route.ts @@ -34,9 +34,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workflowId } = parsed.data.query + const { workflowId, workspaceId } = parsed.data.query - const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, 'read') + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read') if (!access.ok) return access.response const { versions } = await listWorkflowVersions(workflowId) 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 8279549dd2f..94cacfd27f8 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -192,6 +192,7 @@ 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 /** diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index 4727295c2f9..3dead585727 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -7,7 +7,7 @@ */ import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' import { WorkflowLockedError } from '@sim/workflow-authz' -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -102,14 +102,14 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { expect(mockPerformFullDeploy).not.toHaveBeenCalled() }) - it('requires admin workspace permission', async () => { + 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(403) + expect(response.status).toBe(404) expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( expect.objectContaining({ allowed: true }), 'user-1', @@ -119,6 +119,22 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { 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()) @@ -241,14 +257,14 @@ describe('DELETE /api/v1/workflows/[id]/deploy', () => { ) }) - it('requires admin workspace permission', async () => { + 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(403) + 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 index 36ef43510f6..f62421e2e3d 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -11,7 +11,7 @@ import { v1DeployWorkflowContract, v1UndeployWorkflowContract, } from '@/lib/api/contracts/v1/workflows' -import { parseRequest, validationErrorResponse } from '@/lib/api/server' +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' @@ -48,9 +48,9 @@ export const POST = withRouteHandler( const { id } = parsed.data.params - // boundary-raw-json: the deploy body is optional version metadata; tolerate an absent or empty body - const rawBody = await request.json().catch(() => ({})) - const body = v1DeployWorkflowBodySchema.safeParse(rawBody ?? {}) + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) return rawBody.response + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) { return validationErrorResponse(body.error) } @@ -59,14 +59,12 @@ export const POST = withRouteHandler( if (!workflowData?.workspaceId) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + const workspaceId = workflowData.workspaceId - const accessError = await validateWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'admin' - ) - if (accessError) return accessError + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin') + if (accessError) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } await assertWorkflowMutable(id) @@ -77,7 +75,7 @@ export const POST = withRouteHandler( userId, workflowName: workflowData.name || undefined, versionName: body.data.name, - versionDescription: body.data.description, + versionDescription: body.data.description ?? undefined, requestId, request, }) @@ -91,9 +89,9 @@ export const POST = withRouteHandler( captureServerEvent( userId, 'workflow_deployed', - { workflow_id: id, workspace_id: workflowData.workspaceId ?? '' }, + { workflow_id: id, workspace_id: workspaceId }, { - groups: workflowData.workspaceId ? { workspace: workflowData.workspaceId } : undefined, + groups: { workspace: workspaceId }, setOnce: { first_workflow_deployed_at: new Date().toISOString() }, } ) @@ -148,14 +146,12 @@ export const DELETE = withRouteHandler( if (!workflowData?.workspaceId) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + const workspaceId = workflowData.workspaceId - const accessError = await validateWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'admin' - ) - if (accessError) return accessError + 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 }) @@ -176,8 +172,8 @@ export const DELETE = withRouteHandler( captureServerEvent( userId, 'workflow_undeployed', - { workflow_id: id, workspace_id: workflowData.workspaceId ?? '' }, - workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } ) const limits = await getUserLimits(userId) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index fe9496a7557..c1f085faf02 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -6,6 +6,7 @@ * 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' @@ -82,6 +83,15 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { }) }) + 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) @@ -91,6 +101,15 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { 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 }) @@ -177,14 +196,14 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { expect(response.status).toBe(404) }) - it('requires admin workspace permission', async () => { + 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(403) + expect(response.status).toBe(404) expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( expect.objectContaining({ allowed: true }), 'user-1', diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index 59b745b3475..a29cbd9bd1f 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -10,7 +10,7 @@ import { v1RollbackWorkflowBodySchema, v1RollbackWorkflowContract, } from '@/lib/api/contracts/v1/workflows' -import { parseRequest, validationErrorResponse } from '@/lib/api/server' +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' @@ -48,9 +48,9 @@ export const POST = withRouteHandler( const { id } = parsed.data.params - // boundary-raw-json: the rollback body is an optional target version; tolerate an absent or empty body - const rawBody = await request.json().catch(() => ({})) - const body = v1RollbackWorkflowBodySchema.safeParse(rawBody ?? {}) + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) return rawBody.response + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) { return validationErrorResponse(body.error) } @@ -59,14 +59,12 @@ export const POST = withRouteHandler( if (!workflowData?.workspaceId) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + const workspaceId = workflowData.workspaceId - const accessError = await validateWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'admin' - ) - if (accessError) return accessError + 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 }) @@ -113,8 +111,8 @@ export const POST = withRouteHandler( captureServerEvent( userId, 'deployment_version_activated', - { workflow_id: id, workspace_id: workflowData.workspaceId ?? '', version: targetVersion }, - workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } ) const limits = await getUserLimits(userId) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index e88adc7c7d3..6a4d8046fb7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -58,6 +58,8 @@ interface DropdownProps { placeholder?: string /** Enable multi-select mode */ multiSelect?: boolean + /** When false, an empty value stays unset instead of auto-selecting the first option */ + autoSelectFirstOption?: boolean /** Async function to fetch options dynamically */ fetchOptions?: (blockId: string) => Promise> /** Async function to fetch a single option's label by ID (for hydration) */ @@ -91,6 +93,7 @@ export const Dropdown = memo(function Dropdown({ disabled, placeholder = 'Select an option...', multiSelect = false, + autoSelectFirstOption = true, fetchOptions, fetchOptionById, dependsOn, @@ -237,12 +240,12 @@ export const Dropdown = memo(function Dropdown({ return defaultValue } - if (comboboxOptions.length > 0) { + if (autoSelectFirstOption && comboboxOptions.length > 0) { return comboboxOptions[0].value } return undefined - }, [defaultValue, comboboxOptions, multiSelect]) + }, [defaultValue, comboboxOptions, multiSelect, autoSelectFirstOption]) useEffect(() => { if (multiSelect || defaultOptionValue === undefined) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index a27157e1265..f42bf286909 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -682,6 +682,7 @@ function SubBlockComponent({ previewValue={previewValue} disabled={isDisabled} multiSelect={config.multiSelect} + autoSelectFirstOption={config.autoSelectFirstOption} fetchOptions={config.fetchOptions} fetchOptionById={config.fetchOptionById} dependsOn={config.dependsOn} diff --git a/apps/sim/blocks/blocks/deployments.ts b/apps/sim/blocks/blocks/deployments.ts index 9760e21cdc2..2d6b593dd4c 100644 --- a/apps/sim/blocks/blocks/deployments.ts +++ b/apps/sim/blocks/blocks/deployments.ts @@ -11,7 +11,7 @@ export const DeploymentsBlock: BlockConfig = { 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. + - '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. `, @@ -43,6 +43,7 @@ export const DeploymentsBlock: BlockConfig = { mode: 'basic', canonicalParamId: 'workflowId', required: true, + autoSelectFirstOption: false, fetchOptions: () => fetchWorkspaceWorkflowOptions(), }, { @@ -103,8 +104,8 @@ export const DeploymentsBlock: BlockConfig = { if (operation === 'deployments_deploy') { return { workflowId, - name: params.versionName || undefined, - description: params.versionDescription || undefined, + name: params.versionName?.trim() || undefined, + description: params.versionDescription?.trim() || undefined, } } @@ -170,7 +171,7 @@ export const DeploymentsBlock: BlockConfig = { versions: { type: 'json', description: - 'Deployment versions, newest first (id, version, name, isActive, createdAt, createdBy, deployedByName)', + 'Deployment versions, newest first (id, version, name, description, isActive, createdAt, createdBy, deployedByName)', condition: { field: 'operation', value: 'deployments_list_versions' }, }, name: { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index b48ead0699f..10fefccbd4e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -378,6 +378,11 @@ export interface SubBlockConfig { rows?: number // Multi-select functionality multiSelect?: boolean + /** + * Dropdown-specific: when false, an empty value stays unset instead of + * auto-selecting the first option. Defaults to true. + */ + autoSelectFirstOption?: boolean // Combobox specific: Enable search input in dropdown searchable?: boolean /** Dropdown-specific: include static options as Cmd K search entries that preset this subblock. */ diff --git a/apps/sim/lib/api/contracts/tools/deployments.ts b/apps/sim/lib/api/contracts/tools/deployments.ts index a9d29b8ba1e..a27d4c5a989 100644 --- a/apps/sim/lib/api/contracts/tools/deployments.ts +++ b/apps/sim/lib/api/contracts/tools/deployments.ts @@ -1,26 +1,21 @@ import { z } from 'zod' -import { workflowIdSchema } from '@/lib/api/contracts/primitives' +import { deploymentVersionMetadataFieldsSchema } from '@/lib/api/contracts/deployments' +import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' import { defineRouteContract } from '@/lib/api/contracts/types' +/** Bounded to the Postgres `integer` range of `workflow_deployment_version.version`. */ const versionSchema = z .number() .int('Version must be an integer') .min(1, 'Version must be a positive integer') + .max(2147483647, 'Version is out of range') export const deploymentsDeployBodySchema = z.object({ workflowId: workflowIdSchema, - name: z - .string() - .trim() - .min(1, 'Name cannot be empty') - .max(100, 'Name must be 100 characters or less') - .optional(), - description: z - .string() - .trim() - .max(2000, 'Description must be 2000 characters or less') - .optional(), + workspaceId: workspaceIdSchema, + name: deploymentVersionMetadataFieldsSchema.shape.name, + description: deploymentVersionMetadataFieldsSchema.shape.description, }) export type DeploymentsDeployBody = z.input @@ -37,6 +32,7 @@ export const deploymentsDeployContract = defineRouteContract({ export const deploymentsUndeployBodySchema = z.object({ workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, }) export type DeploymentsUndeployBody = z.input @@ -53,6 +49,7 @@ export const deploymentsUndeployContract = defineRouteContract({ export const deploymentsPromoteBodySchema = z.object({ workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, version: versionSchema, }) @@ -70,6 +67,7 @@ export const deploymentsPromoteContract = defineRouteContract({ export const deploymentsListVersionsQuerySchema = z.object({ workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, }) export type DeploymentsListVersionsQuery = z.input @@ -86,6 +84,7 @@ export const deploymentsListVersionsContract = defineRouteContract({ export const deploymentsGetVersionQuerySchema = z.object({ workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema, version: z.coerce.number().pipe(versionSchema), }) diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index ee46fe5d2ba..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' @@ -53,64 +54,72 @@ export const v1GetWorkflowContract = defineRouteContract({ }) /** - * Optional version metadata accepted by the v1 deploy endpoint. The route - * tolerates an absent/empty body, so this schema is validated by the handler - * against a tolerant raw-JSON read instead of being attached to the contract. + * 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: z - .string() - .trim() - .min(1, 'name cannot be empty') - .max(100, 'name must be 100 characters or less') - .optional(), - description: z - .string() - .trim() - .max(2000, 'description must be 2000 characters or less') - .optional(), + 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 a - * tolerant raw-JSON read, so it is not attached to the contract. + * 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: z - .number() - .int('version must be an integer') - .min(1, 'version must be a positive integer') - .optional(), + version: deploymentVersionNumberSchema.optional(), }) export type V1RollbackWorkflowBody = z.input -export const v1WorkflowDeploymentDataSchema = z.object({ +const v1DeploymentStateSchema = z.object({ id: z.string(), isDeployed: z.boolean(), deployedAt: z.string().nullable(), - version: z.number().optional(), warnings: z.array(z.string()), }) -export type V1WorkflowDeploymentData = z.output +export const v1DeployWorkflowDataSchema = v1DeploymentStateSchema.extend({ + version: z.number().optional(), +}) + +export type V1DeployWorkflowData = z.output -const v1WorkflowDeploymentResponseSchema = z.object({ - data: v1WorkflowDeploymentDataSchema, - limits: z.unknown().optional(), +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: v1WorkflowDeploymentResponseSchema, + schema: withV1Limits(v1DeployWorkflowDataSchema), }, }) @@ -120,7 +129,7 @@ export const v1UndeployWorkflowContract = defineRouteContract({ params: workflowIdParamsSchema, response: { mode: 'json', - schema: v1WorkflowDeploymentResponseSchema, + schema: withV1Limits(v1DeploymentStateSchema), }, }) @@ -130,6 +139,6 @@ export const v1RollbackWorkflowContract = defineRouteContract({ params: workflowIdParamsSchema, response: { mode: 'json', - schema: v1WorkflowDeploymentResponseSchema, + 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/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 7a61943b5cb..28c9ae95806 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -1191,6 +1191,7 @@ export async function listWorkflowVersions(workflowId: string): Promise<{ id: string version: number name: string | null + description: string | null isActive: boolean createdAt: Date createdBy: string | null @@ -1199,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, @@ -1214,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/tools/deployments/deploy.ts b/apps/sim/tools/deployments/deploy.ts index 667b88fc374..f86ec99b398 100644 --- a/apps/sim/tools/deployments/deploy.ts +++ b/apps/sim/tools/deployments/deploy.ts @@ -34,11 +34,18 @@ export const deploymentsDeployTool: ToolConfig ({ 'Content-Type': 'application/json' }), - body: (params) => ({ - workflowId: params.workflowId, - ...(params.name ? { name: params.name } : {}), - ...(params.description ? { description: params.description } : {}), - }), + 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(), @@ -46,8 +53,15 @@ export const deploymentsDeployTool: ToolConfig - `/api/tools/deployments/version?workflowId=${encodeURIComponent(params.workflowId)}&version=${params.version}`, + 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' }), }, diff --git a/apps/sim/tools/deployments/list_versions.ts b/apps/sim/tools/deployments/list_versions.ts index a103b48c219..ce349bd6723 100644 --- a/apps/sim/tools/deployments/list_versions.ts +++ b/apps/sim/tools/deployments/list_versions.ts @@ -24,8 +24,14 @@ export const deploymentsListVersionsTool: ToolConfig< }, request: { - url: (params) => - `/api/tools/deployments/versions?workflowId=${encodeURIComponent(params.workflowId)}`, + 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' }), }, @@ -37,7 +43,7 @@ export const deploymentsListVersionsTool: ToolConfig< versions: { type: 'array', description: - 'Deployment versions, newest first (id, version, name, isActive, createdAt, createdBy, deployedByName)', + '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 index 2d4ff77cc1c..9b45e9cd346 100644 --- a/apps/sim/tools/deployments/promote.ts +++ b/apps/sim/tools/deployments/promote.ts @@ -11,7 +11,7 @@ export const deploymentsPromoteTool: ToolConfig< 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. Requires admin permission on the workflow’s workspace.', + '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: { @@ -33,10 +33,17 @@ export const deploymentsPromoteTool: ToolConfig< url: '/api/tools/deployments/promote', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), - body: (params) => ({ - workflowId: params.workflowId, - version: params.version, - }), + 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(), @@ -44,7 +51,10 @@ export const deploymentsPromoteTool: ToolConfig< 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' }, + 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', diff --git a/apps/sim/tools/deployments/types.ts b/apps/sim/tools/deployments/types.ts index 82358357796..4ddff9d616a 100644 --- a/apps/sim/tools/deployments/types.ts +++ b/apps/sim/tools/deployments/types.ts @@ -1,33 +1,39 @@ -import type { ToolResponse } from '@/tools/types' +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 diff --git a/apps/sim/tools/deployments/undeploy.ts b/apps/sim/tools/deployments/undeploy.ts index cd87fde7e4d..40bd7110451 100644 --- a/apps/sim/tools/deployments/undeploy.ts +++ b/apps/sim/tools/deployments/undeploy.ts @@ -27,7 +27,13 @@ export const deploymentsUndeployTool: ToolConfig< url: '/api/tools/deployments/undeploy', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), - body: (params) => ({ workflowId: params.workflowId }), + 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(), From 94870e2481e33bd6a3bf088b4f4bd217e68c7e05 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 15:44:27 -0700 Subject: [PATCH 4/6] chore(api-validation): bump route baseline for rebased staging (826) --- scripts/check-api-validation-contracts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 079b6a6fe107c8b2dfe1be7fdc5cbea70f584abc Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 16:11:32 -0700 Subject: [PATCH 5/6] fix(docs): use real ID formats in OpenAPI examples Workflow, workspace, folder, knowledge-base, document, and execution IDs are plain UUIDv4; workspace file IDs are wf_; table and row IDs are tbl_/row_ + de-dashed UUID. Replaces all fake prefixed example IDs (wf_abc123, ws_xyz789, exec_..., kb_..., etc.) accordingly and marks the deploy body description as nullable to match the shared schema. --- .../docs/en/workflows/deployment/api.mdx | 6 +- apps/docs/openapi.json | 202 +++++++++--------- 2 files changed, 109 insertions(+), 99 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/deployment/api.mdx b/apps/docs/content/docs/en/workflows/deployment/api.mdx index 15ade28711d..b2f713bed40 100644 --- a/apps/docs/content/docs/en/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/api.mdx @@ -248,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 } } ``` @@ -276,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/openapi.json b/apps/docs/openapi.json index 19fb5a4e00c..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, @@ -1126,7 +1126,7 @@ "description": "The unique workflow identifier.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } } ], @@ -1149,7 +1149,8 @@ "type": "string", "maxLength": 2000, "description": "Optional summary of what changed in this version.", - "example": "Fixes the agent prompt" + "example": "Fixes the agent prompt", + "nullable": true } } } @@ -1176,7 +1177,7 @@ }, "example": { "data": { - "id": "wf_abc123", + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "isDeployed": true, "deployedAt": "2026-06-12T10:30:00Z", "version": 4, @@ -1237,7 +1238,7 @@ "description": "The unique workflow identifier.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } } ], @@ -1261,7 +1262,7 @@ }, "example": { "data": { - "id": "wf_abc123", + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "isDeployed": false, "deployedAt": null, "warnings": [] @@ -1323,7 +1324,7 @@ "description": "The unique workflow identifier.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } } ], @@ -1367,7 +1368,7 @@ }, "example": { "data": { - "id": "wf_abc123", + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "isDeployed": true, "deployedAt": "2026-06-12T10:30:00Z", "version": 3, @@ -1662,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" @@ -1731,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, @@ -1778,7 +1779,7 @@ "description": "The unique execution identifier.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -1857,8 +1858,8 @@ } }, "example": { - "executionId": "exec_abc123", - "workflowId": "wf_abc123", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "workflowState": {}, "executionMetadata": {} } @@ -2080,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" } @@ -2215,7 +2216,7 @@ "data": { "tables": [ { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2364,7 +2365,7 @@ "success": true, "data": { "table": { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2454,7 +2455,7 @@ "success": true, "data": { "table": { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -3032,7 +3033,7 @@ "data": { "rows": [ { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe" @@ -3200,7 +3201,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe", @@ -3324,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": [ @@ -3383,7 +3384,10 @@ }, "example": { "workspaceId": "wsp_abc123", - "rowIds": ["row_abc123", "row_def456"] + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } @@ -3439,7 +3443,10 @@ "data": { "message": "Rows deleted successfully", "deletedCount": 2, - "deletedRowIds": ["row_abc123", "row_def456"] + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } @@ -3472,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": [ @@ -3519,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" } @@ -3610,7 +3617,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe" @@ -3722,7 +3729,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Updated Name" @@ -3940,7 +3947,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "user@example.com", "name": "John Doe" @@ -4028,7 +4035,7 @@ "data": { "files": [ { - "id": "file_abc123", + "id": "wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", "size": 1024, "type": "text/csv", @@ -4124,7 +4131,7 @@ "success": true, "data": { "file": { - "id": "file_abc123", + "id": "wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", "size": 1024, "type": "text/csv", @@ -4192,7 +4199,7 @@ "description": "The unique identifier of the file.", "schema": { "type": "string", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" } }, { @@ -4291,7 +4298,7 @@ "description": "The unique identifier of the file to delete.", "schema": { "type": "string", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" } }, { @@ -4404,7 +4411,7 @@ "data": { "knowledgeBases": [ { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Product Docs", "description": "Product documentation and FAQs", "docCount": 5, @@ -4515,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, @@ -4602,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, @@ -4725,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, @@ -4981,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", @@ -5099,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", @@ -5247,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", @@ -5426,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 } @@ -5484,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.", @@ -5494,7 +5501,7 @@ } ], "query": "How do I reset my password?", - "knowledgeBaseIds": ["kb_abc123"], + "knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"], "topK": 5, "totalResults": 1 } @@ -5538,7 +5545,7 @@ "required": true, "schema": { "type": "string", - "example": "tbl_abc123" + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" }, "description": "The unique identifier of the table." }, @@ -5548,7 +5555,7 @@ "required": true, "schema": { "type": "string", - "example": "row_xyz789" + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" }, "description": "The unique identifier of the row." }, @@ -5598,7 +5605,7 @@ "id": { "type": "string", "description": "Unique table identifier.", - "example": "tbl_abc123" + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" }, "name": { "type": "string", @@ -5650,7 +5657,7 @@ "id": { "type": "string", "description": "Unique row identifier.", - "example": "row_xyz789" + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" }, "data": { "type": "object", @@ -5680,7 +5687,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -5697,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", @@ -5749,7 +5756,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -5766,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", @@ -5835,7 +5842,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "isDeployed": { "type": "boolean", @@ -5875,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", @@ -5938,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", @@ -5965,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", @@ -6030,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", @@ -6071,7 +6078,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -6460,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", @@ -6494,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", @@ -6618,7 +6625,7 @@ "id": { "type": "string", "description": "Unique file identifier.", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" }, "name": { "type": "string", @@ -7429,7 +7436,10 @@ "data": { "message": "Rows updated successfully", "updatedCount": 2, - "updatedRowIds": ["row_abc123", "row_def456"] + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } From 1543da4de027afde6025078eca886084aa751169 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 16:29:24 -0700 Subject: [PATCH 6/6] feat(deployments): resolve workflow names in block UI, add workflow_undeployed Sim trigger event - Deployments block now uses the workflow-selector subblock (same as the Workflow block), so the canvas tile shows the workflow name instead of the raw ID; reverts the now-unneeded dropdown autoSelectFirstOption prop - Adds workflow_undeployed to the Sim workspace-event trigger, emitted by performFullUndeploy through a shared lifecycle-event dispatch loop --- .../docs/en/workflows/triggers/sim.mdx | 2 + .../components/dropdown/dropdown.tsx | 7 +- .../editor/components/sub-block/sub-block.tsx | 1 - apps/sim/blocks/blocks/deployments.ts | 7 +- apps/sim/blocks/types.ts | 5 -- .../sim/lib/workflows/orchestration/deploy.ts | 14 +++- apps/sim/lib/workspace-events/constants.ts | 1 + apps/sim/lib/workspace-events/emitter.ts | 77 +++++++++++++++---- apps/sim/lib/workspace-events/payload.ts | 14 +++- apps/sim/triggers/sim/workspace-event.test.ts | 6 ++ apps/sim/triggers/sim/workspace-event.ts | 1 + 11 files changed, 100 insertions(+), 35 deletions(-) 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/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 6a4d8046fb7..e88adc7c7d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -58,8 +58,6 @@ interface DropdownProps { placeholder?: string /** Enable multi-select mode */ multiSelect?: boolean - /** When false, an empty value stays unset instead of auto-selecting the first option */ - autoSelectFirstOption?: boolean /** Async function to fetch options dynamically */ fetchOptions?: (blockId: string) => Promise> /** Async function to fetch a single option's label by ID (for hydration) */ @@ -93,7 +91,6 @@ export const Dropdown = memo(function Dropdown({ disabled, placeholder = 'Select an option...', multiSelect = false, - autoSelectFirstOption = true, fetchOptions, fetchOptionById, dependsOn, @@ -240,12 +237,12 @@ export const Dropdown = memo(function Dropdown({ return defaultValue } - if (autoSelectFirstOption && comboboxOptions.length > 0) { + if (comboboxOptions.length > 0) { return comboboxOptions[0].value } return undefined - }, [defaultValue, comboboxOptions, multiSelect, autoSelectFirstOption]) + }, [defaultValue, comboboxOptions, multiSelect]) useEffect(() => { if (multiSelect || defaultOptionValue === undefined) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index f42bf286909..a27157e1265 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -682,7 +682,6 @@ function SubBlockComponent({ previewValue={previewValue} disabled={isDisabled} multiSelect={config.multiSelect} - autoSelectFirstOption={config.autoSelectFirstOption} fetchOptions={config.fetchOptions} fetchOptionById={config.fetchOptionById} dependsOn={config.dependsOn} diff --git a/apps/sim/blocks/blocks/deployments.ts b/apps/sim/blocks/blocks/deployments.ts index 2d6b593dd4c..4a101f1ba33 100644 --- a/apps/sim/blocks/blocks/deployments.ts +++ b/apps/sim/blocks/blocks/deployments.ts @@ -1,5 +1,4 @@ import { SimDeploymentsIcon } from '@/components/icons' -import { fetchWorkspaceWorkflowOptions } from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' export const DeploymentsBlock: BlockConfig = { @@ -37,14 +36,12 @@ export const DeploymentsBlock: BlockConfig = { { id: 'workflowSelector', title: 'Workflow', - type: 'dropdown', - options: [], + type: 'workflow-selector', + selectorKey: 'sim.workflows', placeholder: 'Select workflow', mode: 'basic', canonicalParamId: 'workflowId', required: true, - autoSelectFirstOption: false, - fetchOptions: () => fetchWorkspaceWorkflowOptions(), }, { id: 'manualWorkflowId', diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 10fefccbd4e..b48ead0699f 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -378,11 +378,6 @@ export interface SubBlockConfig { rows?: number // Multi-select functionality multiSelect?: boolean - /** - * Dropdown-specific: when false, an empty value stays unset instead of - * auto-selecting the first option. Defaults to true. - */ - autoSelectFirstOption?: boolean // Combobox specific: Enable search input in dropdown searchable?: boolean /** Dropdown-specific: include static options as Cmd K search entries that preset this subblock. */ 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/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/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' },