From 1f7728b1e0276e5fe6ae0c5c8231dda62f5ea31e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 12:30:15 -0800 Subject: [PATCH 01/21] fix(execution): honor pinned deployment version executeWorkflowService loaded the pinned deployment version only to read its variables and blocks; the execution metadata carried no pin, so the core loaded and ran the active deployment. Carry the pin as pinnedDeploymentVersionId on the sync, stream, and async paths and load that version in the core. MCP serve, which already passed a pin, now runs the version it selected. --- apps/sim/background/workflow-execution.ts | 19 +++++++++++++++++- apps/sim/executor/execution/types.ts | 6 ++++++ .../workflows/executor/enqueue-execution.ts | 4 ++++ .../lib/workflows/executor/execute-service.ts | 8 +++++++- .../workflows/executor/execute-workflow.ts | 3 +++ .../lib/workflows/executor/execution-core.ts | 20 +++++++++++++++++++ 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index 0aa2c684e8a..d148aec63ec 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -47,6 +47,7 @@ import { isRecordedWorkflowFailure, resolveDeletedExecutionScope, } from '@/lib/workflows/executor/recorded-failure' +import { loadWorkflowDeploymentVersionState } from '@/lib/workflows/persistence/utils' import { WORKFLOW_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' @@ -106,6 +107,11 @@ export type WorkflowExecutionPayload = { */ enforceCredentialAccess?: boolean isPublicApiAccess?: boolean + /** + * Deployment version validated by the enqueuing use case. Only the id rides the + * payload; jobs queued without it run the active deployment. + */ + pinnedDeploymentVersionId?: string } type LegacyWorkflowExecutionPayload = Omit & { @@ -272,6 +278,16 @@ export async function executeWorkflowJob( logger.info(`Preprocessing passed. Using actor: ${actorUserId}`, { requestId }) const workflow = preprocessResult.workflowRecord! + const pinnedDeploymentVersionId = payload.pinnedDeploymentVersionId + const pinnedVariables = pinnedDeploymentVersionId + ? ( + await loadWorkflowDeploymentVersionState( + workflowId, + pinnedDeploymentVersionId, + workspaceId + ) + ).variables + : undefined const metadata: ExecutionMetadata = { requestId, @@ -286,6 +302,7 @@ export async function executeWorkflowJob( triggerType: payload.triggerType || 'api', triggerBlockId: payload.triggerBlockId, useDraftState: false, + pinnedDeploymentVersionId, startTime: new Date().toISOString(), isClientSession: false, enforceCredentialAccess: payload.enforceCredentialAccess ?? false, @@ -299,7 +316,7 @@ export async function executeWorkflowJob( metadata, workflow, payload.input, - workflow.variables || {}, + pinnedVariables ?? workflow.variables ?? {}, [] ) diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 905dc3ac6ec..36265be745e 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -75,6 +75,12 @@ export interface ExecutionMetadata { variables?: Record deploymentVersionId?: string } + /** + * Deployment version a deployed run executes instead of the active one, chosen + * and validated by the application use case. A resumed run ignores it: resume + * reloads the version its durable log admitted. + */ + pinnedDeploymentVersionId?: string largeValueExecutionIds?: string[] largeValueKeys?: string[] fileKeys?: string[] diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index 8c32ba067c2..0190d195bda 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -39,6 +39,8 @@ export interface EnqueueWorkflowExecutionParams { /** Identity decisions the enqueuing surface made; the worker cannot re-derive them. */ enforceCredentialAccess?: boolean isPublicApiAccess?: boolean + /** Deployment version the run executes instead of the active one. */ + pinnedDeploymentVersionId?: string } /** @@ -86,6 +88,7 @@ export async function enqueueWorkflowExecution( trustedInitialResolvedSecretTraceProvenance, enforceCredentialAccess, isPublicApiAccess, + pinnedDeploymentVersionId, } = params const asyncLogger = logger.withMetadata({ requestId, @@ -124,6 +127,7 @@ export async function enqueueWorkflowExecution( admissionCompleted: true, executionTimeoutMs, trustedInitialResolvedSecretTraceProvenance, + ...(pinnedDeploymentVersionId ? { pinnedDeploymentVersionId } : {}), } let jobQueue: Awaited> diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 5438f6a0776..cab18957181 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -88,7 +88,10 @@ export interface ExecuteWorkflowServiceParams { /** Pre-fetched workflow row (already authorized by the caller). */ workflowRecord?: WorkflowRecord upstreamBillingAttribution?: BillingAttributionSnapshot - /** Pin execution to a specific deployment version (MCP bridge). */ + /** + * Run this deployment version instead of the active one (MCP bridge, v2 execute + * with `version`). The id must already be validated against `workflowId`. + */ deploymentVersionId?: string includeFileBase64?: boolean base64MaxBytes?: number @@ -473,6 +476,7 @@ export async function executeWorkflowService( enforceCredentialAccess: useAuthenticatedUserAsActor, isPublicApiAccess, executionTimeoutMs: preprocessResult.executionTimeout.async, + pinnedDeploymentVersionId: deploymentVersionId, }) executionIdClaimCommitted = enqueue.retainExecutionClaim if (enqueue.outcome === 'rejected') { @@ -627,6 +631,7 @@ export async function executeWorkflowService( triggerBlockId, useDraftState, draftState, + pinnedDeploymentVersionId: deploymentVersionId, runFromBlock, stopAfterBlockId, onStream, @@ -678,6 +683,7 @@ export async function executeWorkflowService( triggerType, triggerBlockId, useDraftState, + pinnedDeploymentVersionId: deploymentVersionId, startTime: new Date().toISOString(), isClientSession: false, enforceCredentialAccess: useAuthenticatedUserAsActor, diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 052cd0d7deb..6d227db04fb 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -60,6 +60,8 @@ export interface ExecuteWorkflowOptions { draftState?: NormalizedWorkflowData /** Immutable workflow state selected by a trusted server-side trigger boundary. */ workflowStateOverride?: NonNullable + /** See {@link ExecutionMetadata.pinnedDeploymentVersionId}. */ + pinnedDeploymentVersionId?: string /** Stop execution after this block completes. Used for "run until block" feature. */ stopAfterBlockId?: string /** Workflow test run: mocked blocks await these hooks instead of running. */ @@ -172,6 +174,7 @@ export async function executeWorkflow( triggerBlockId: streamConfig?.triggerBlockId, useDraftState: streamConfig?.useDraftState ?? false, workflowStateOverride: streamConfig?.workflowStateOverride, + pinnedDeploymentVersionId: streamConfig?.pinnedDeploymentVersionId, startTime: new Date().toISOString(), isClientSession: false, enforceCredentialAccess: streamConfig?.enforceCredentialAccess ?? false, diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 9bcad561ff9..c5b7296286f 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -686,6 +686,10 @@ async function executeWorkflowCoreImpl( if (resumeFromSnapshot && !useDraftState && !resumeDeploymentVersionId) { throw new Error('Deployed resume requires its admitted deployment version') } + const pinnedDeploymentVersionId = metadata.pinnedDeploymentVersionId + if (pinnedDeploymentVersionId && (useDraftState || metadata.workflowStateOverride)) { + throw new Error('A pinned deployment version cannot run draft or overridden workflow state') + } let processedInput = input || {} let deploymentVersionId: string | undefined @@ -743,6 +747,22 @@ async function executeWorkflowCoreImpl( } } + if (pinnedDeploymentVersionId) { + const pinnedData = await loadWorkflowDeploymentVersionState( + workflowId, + pinnedDeploymentVersionId, + providedWorkspaceId + ) + logger.info('Using pinned deployment version state', { requestId }) + return { + blocks: pinnedData.blocks, + edges: pinnedData.edges, + loops: pinnedData.loops, + parallels: pinnedData.parallels, + deploymentVersionId: pinnedData.deploymentVersionId, + } + } + if (metadata.workflowStateOverride) { const override = metadata.workflowStateOverride logger.debug('Using workflow state override (diff workflow execution)', { From ff29edcdd507f4af71b197475dc28d79bc894cba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 12:37:04 -0800 Subject: [PATCH 02/21] feat(api): pin a deployment version on v2 execute run: { source: 'deployment', version } runs that deployment version instead of the active one. The pin is its own write-role operation, workflows.execute_version, selected after parsing; the use case resolves the number to the version row and answers an unknown version with 404. Anonymous public-API callers cannot pin. The run resource reports the deployment version each run executed. --- apps/docs/openapi-v2-workflows.json | 28 +- .../workflows/[workflowId]/execute/route.ts | 22 +- .../[workflowId]/runs/[runId]/route.test.ts | 1 + .../[workflowId]/runs/[runId]/route.ts | 1 + .../executions/[executionId]/route.test.ts | 1 + .../lib/api/contracts/v2/openapi/workflows.ts | 3 +- apps/sim/lib/api/contracts/v2/workflows.ts | 23 +- apps/sim/lib/api/contracts/workflows.ts | 2 + .../lib/api/mcp/generated/v2-operations.ts | 2 +- .../workflows/application/execute-workflow.ts | 96 +++- .../lib/workflows/application/operations.ts | 10 + .../executor/execution-status.test.ts | 1 + .../workflows/executor/execution-status.ts | 15 +- apps/sim/lib/workflows/persistence/utils.ts | 22 + .../test-workflow-pinned-execute-e2e.ts | 483 ++++++++++++++++++ packages/sim-cli/src/generated/v2-api.ts | 4 +- 16 files changed, 673 insertions(+), 41 deletions(-) create mode 100644 apps/sim/scripts/test-workflow-pinned-execute-e2e.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 404c5d208ce..f37787535e9 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3718,7 +3718,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute workflow", - "description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", + "description": "Run the active deployment, set `run: { source: \"deployment\", version }` to run an earlier deployment version, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns `404`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.execute", "x-oauth-scope": "api:write", "tags": ["Workflows"], @@ -4277,6 +4277,7 @@ "startedAt": "2026-08-09T18:04:10.000Z", "endedAt": "2026-08-09T18:04:11.000Z", "durationMs": 1000, + "version": 3, "paused": null, "cost": { "total": 12 @@ -14451,7 +14452,7 @@ } }, "run": { - "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only; `waitTimeoutSeconds` lets a synchronous one continue in the background.", + "description": "Workflow state and entry point to execute. Omit for the active deployment; `source: \"deployment\"` with `version` runs that deployment version. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only; `waitTimeoutSeconds` lets a synchronous one continue in the background.", "oneOf": [ { "type": "object", @@ -14459,7 +14460,13 @@ "source": { "type": "string", "const": "deployment", - "description": "Execute the active deployed workflow state." + "description": "Execute a deployed workflow version." + }, + "version": { + "description": "Deployment version to execute instead of the active one. Requires an API key or OAuth access token with workspace write access, and the workflow must be deployed. An unknown version returns `404`.", + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, "required": ["source"], @@ -14896,6 +14903,19 @@ ], "description": "Run duration in milliseconds, or null while active." }, + "version": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Deployment version the run executed. Null for a manual run, a queued run not yet started, or a run whose version no longer exists." + }, "paused": { "anyOf": [ { @@ -15073,6 +15093,7 @@ "startedAt", "endedAt", "durationMs", + "version", "paused", "cost", "error", @@ -15106,6 +15127,7 @@ "startedAt": "2026-08-09T18:04:10.000Z", "endedAt": "2026-08-09T18:04:11.000Z", "durationMs": 1000, + "version": 3, "paused": null, "cost": { "total": 12 diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index 24fbbcd3417..a97fe7c4d0a 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -40,7 +40,10 @@ import { executeManualWorkflowFromBlockOperation, executeManualWorkflowOperation, } from '@/lib/workflows/application/execute-manual-workflow' -import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' +import { + executeWorkflowOperation, + executeWorkflowVersionOperation, +} from '@/lib/workflows/application/execute-workflow' import { workflowOperations } from '@/lib/workflows/application/operations' import { type ExecuteWorkflowServiceFailure, @@ -316,6 +319,8 @@ type V2ExecuteWorkflowRouteContext = { * * - Auth: `X-API-Key` (personal/workspace) or the anonymous public-API path for * workflows deployed with `isPublicApi` (actor = owner; sync/stream only). + * - `run: { source: 'deployment', version }` runs that deployment version under + * the write-role `workflows.execute_version` operation; keyed callers only. * - `async: true` (body flag — v2 has no mode headers) → 202 * `{ data: { runId, statusUrl } }`; poll the v2 runs resource. * - `stream: true` → SSE passthrough (no `{data}` envelope on event frames). @@ -422,6 +427,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const body = parsed.data.body const manualRun = body.run?.source === 'manual' ? body.run : undefined + const pinnedVersion = body.run?.source === 'deployment' ? body.run.version : undefined if (manualRun && !apiKeyPrincipal) { return v2Error( @@ -439,6 +445,9 @@ export const POST = withRouteHandler( ) { return v2Error('BAD_REQUEST', 'input and run.entry.useMockPayload cannot be combined') } + if (pinnedVersion !== undefined && isPublicApiAccess) { + return v2Error('BAD_REQUEST', 'run.version requires an OAuth access token or API key') + } const detachable = body.waitTimeoutSeconds !== undefined if (detachable && (body.async || body.stream)) { @@ -544,6 +553,17 @@ export const POST = withRouteHandler( }, request: req, }) + } else if (pinnedVersion !== undefined) { + result = await executeWorkflowVersionOperation.execute({ + principal: apiKeyPrincipal, + input: { + ...commonInput, + input: body.input ?? {}, + mode: body.async ? 'async' : body.stream ? 'stream' : syncMode, + version: pinnedVersion, + }, + request: req, + }) } else { result = await executeWorkflowOperation.execute({ principal: apiKeyPrincipal, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts index 3a78c29156b..0dac98967dd 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts @@ -58,6 +58,7 @@ const baseStatus = { startedAt: '2026-07-31T00:00:00.000Z', endedAt: '2026-07-31T00:00:05.000Z', totalDurationMs: 5000, + deploymentVersion: null, paused: null, cost: { total: 0.02 }, error: 'Send Email: Invalid credentials', diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts index dab2588457e..8f3bef26ba7 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts @@ -45,6 +45,7 @@ export const GET = defineV2JsonRoute({ startedAt: status.startedAt, endedAt: status.endedAt, durationMs: status.totalDurationMs, + version: status.deploymentVersion, paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse(status.paused) : null, cost: status.cost, error: status.error ? classifyExecutionError(new Error(status.error)) : null, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts index f6984733410..1a5ffa73f25 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts @@ -53,6 +53,7 @@ describe('internal execution status route projection subject', () => { startedAt: '2026-08-05T12:00:00.000Z', endedAt: null, totalDurationMs: null, + deploymentVersion: null, paused: null, cost: null, error: null, diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 5637d6fe7fd..3c2ec2e81d6 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -1210,7 +1210,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.execute, operationId: 'executeWorkflowV2', summary: 'Execute workflow', - description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, + description: `Run the active deployment, set \`run: { source: "deployment", version }\` to run an earlier deployment version, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns \`404\`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, errors: [ 'BadRequest', 'Unauthorized', @@ -1316,6 +1316,7 @@ const declaredRoutes = [ startedAt: '2026-08-09T18:04:10.000Z', endedAt: '2026-08-09T18:04:11.000Z', durationMs: 1_000, + version: 3, paused: null, cost: { total: 12 }, error: null, diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index ecf991224de..20f25451e18 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1383,7 +1383,12 @@ export const EXECUTE_OPTION_CONSTRAINTS = export const v2WorkflowRunSelectionSchema = z.discriminatedUnion('source', [ z .object({ - source: z.literal('deployment').describe('Execute the active deployed workflow state.'), + source: z.literal('deployment').describe('Execute a deployed workflow version.'), + version: versionNumberSchema + .optional() + .describe( + 'Deployment version to execute instead of the active one. Requires an API key or OAuth access token with workspace write access, and the workflow must be deployed. An unknown version returns `404`.' + ), }) .strict(), z @@ -1448,9 +1453,10 @@ export type V2WorkflowRunSelection = z.input import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), }, diff --git a/apps/sim/lib/workflows/application/execute-workflow.ts b/apps/sim/lib/workflows/application/execute-workflow.ts index 125f393f787..817cfb9d8aa 100644 --- a/apps/sim/lib/workflows/application/execute-workflow.ts +++ b/apps/sim/lib/workflows/application/execute-workflow.ts @@ -1,16 +1,21 @@ import type { Principal } from '@sim/auth/principal' import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { inlinesRunFiles, withholdUnadmittedInlineFiles, } from '@/lib/workflows/application/admit-run-files' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { type ExecuteWorkflowServiceResult, executeWorkflowService, } from '@/lib/workflows/executor/execute-service' +import { getWorkflowDeploymentVersionId } from '@/lib/workflows/persistence/utils' export interface ExecuteWorkflowInput { workflowId: string @@ -39,37 +44,72 @@ function authenticatesExecutionCredentials(principal: Principal): boolean { return principal.kind !== 'workspace_api_key' } +async function executeDeployedWorkflow( + principal: Principal, + context: ActiveWorkflowApplicationContext, + input: ExecuteWorkflowInput, + deploymentVersionId?: string +): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await executeWorkflowService({ + workflowId: context.workflowId, + principal, + userId: attribution.attributedUserId, + input: input.input, + triggerType: 'api', + requestId: input.requestId, + executionId: input.executionId, + callChain: input.callChain, + useAuthenticatedUserAsActor: authenticatesExecutionCredentials(principal), + workflowRecord: context.workflow, + deploymentVersionId, + includeFileBase64: inlinesRunFiles(principal, input.includeFileBase64, input.mode), + base64MaxBytes: input.base64MaxBytes, + selectedOutputs: input.selectedOutputs, + rateLimitCounter: input.mode === 'async' ? 'async' : 'sync', + requestedTimeoutSeconds: input.requestedTimeoutSeconds, + abortSignal: input.abortSignal, + mode: input.mode, + detachable: input.detachable, + requestHeaders: input.requestHeaders, + includeThinking: input.includeThinking, + includeToolCalls: input.includeToolCalls, + }) + return withholdUnadmittedInlineFiles(principal, context.workspaceId, result) +} + export const executeWorkflowOperation = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.execute, resolveContext: ({ input }: { input: ExecuteWorkflowInput }) => resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + execute: ({ principal, context, input }) => executeDeployedWorkflow(principal, context, input), +}) + +interface ExecuteWorkflowVersionInput extends ExecuteWorkflowInput { + /** Deployment version number to run instead of the active deployment. */ + version: number +} + +/** + * Runs a chosen deployment version of a deployed workflow. Picking which graph + * runs is a deployment decision, so it needs write access where running the + * active deployment needs read. The workflow must still be deployed: the + * service's deployment check applies to a pinned run as to any deployed one. + */ +export const executeWorkflowVersionOperation = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.executeVersion, + resolveContext: ({ input }: { input: ExecuteWorkflowVersionInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), async execute({ principal, context, input }): Promise { - const attribution = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }) - const result = await executeWorkflowService({ - workflowId: context.workflowId, - principal, - userId: attribution.attributedUserId, - input: input.input, - triggerType: 'api', - requestId: input.requestId, - executionId: input.executionId, - callChain: input.callChain, - useAuthenticatedUserAsActor: authenticatesExecutionCredentials(principal), - workflowRecord: context.workflow, - includeFileBase64: inlinesRunFiles(principal, input.includeFileBase64, input.mode), - base64MaxBytes: input.base64MaxBytes, - selectedOutputs: input.selectedOutputs, - rateLimitCounter: input.mode === 'async' ? 'async' : 'sync', - requestedTimeoutSeconds: input.requestedTimeoutSeconds, - abortSignal: input.abortSignal, - mode: input.mode, - detachable: input.detachable, - requestHeaders: input.requestHeaders, - includeThinking: input.includeThinking, - includeToolCalls: input.includeToolCalls, - }) - return withholdUnadmittedInlineFiles(principal, context.workspaceId, result) + const deploymentVersionId = await getWorkflowDeploymentVersionId( + context.workflowId, + input.version + ) + if (!deploymentVersionId) { + throw new OrchestrationError('not_found', 'Version not found') + } + return executeDeployedWorkflow(principal, context, input, deploymentVersionId) }, }) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index dec0ed036fd..713e677f1b3 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -443,6 +443,16 @@ export const workflowOperations = { contentDelivery: 'sourced', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a chosen deployment version is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation + executeVersion: defineWorkspaceOperation({ + id: 'workflows.execute_version', + oauthScope: 'api:write', + minimumRole: 'write', + workspaceApiKey: 'allow', + capability: 'none', + contentDelivery: 'sourced', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), // permission-group-exempt: a manual run is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation executeManual: defineWorkspaceOperation({ id: 'workflows.manual.execute', diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index f69cd41431a..f72c3feb4be 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -454,6 +454,7 @@ describe('getWorkflowExecutionStatus settled resume attempts', () => { startedAt: '2026-08-05T12:00:01.000Z', endedAt: '2026-08-05T12:00:05.000Z', totalDurationMs: 4000, + deploymentVersion: null, paused: null, cost: { total: 0.5 }, error: null, diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index ec854781039..1141c96ecde 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -1,5 +1,10 @@ import { db } from '@sim/db' -import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' +import { + pausedExecutions, + resumeQueue, + workflowDeploymentVersion, + workflowExecutionLogs, +} from '@sim/db/schema' import { and, eq, inArray, sql } from 'drizzle-orm' import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' import { getJobQueue } from '@/lib/core/async-jobs' @@ -105,6 +110,7 @@ function projectQueueJob( startedAt: startedAt.toISOString(), endedAt: endedAt?.toISOString() ?? null, totalDurationMs: endedAt ? endedAt.getTime() - startedAt.getTime() : null, + deploymentVersion: null, paused: null, cost: null, error: status === 'failed' ? (job.error ?? 'Execution failed') : null, @@ -316,8 +322,13 @@ async function readWorkflowExecutionStatus( totalDurationMs: workflowExecutionLogs.totalDurationMs, executionData: executionDataReadByPoll(input), costTotal: workflowExecutionLogs.costTotal, + deploymentVersion: workflowDeploymentVersion.version, }) .from(workflowExecutionLogs) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) .where( and( eq(workflowExecutionLogs.executionId, executionId), @@ -380,6 +391,7 @@ async function readWorkflowExecutionStatus( startedAt: startedAt.toISOString(), endedAt: null, totalDurationMs: null, + deploymentVersion: logRow?.deploymentVersion ?? null, paused: null, cost: null, error: null, @@ -452,6 +464,7 @@ async function readWorkflowExecutionStatus( startedAt: logRow.startedAt.toISOString(), endedAt: logRow.endedAt?.toISOString() ?? null, totalDurationMs: logRow.totalDurationMs ?? null, + deploymentVersion: logRow.deploymentVersion ?? null, paused, cost, } diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 86e0862dfab..c198eb282de 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -1122,6 +1122,28 @@ export async function getWorkflowDeploymentVersion( return row ?? null } +/** + * Resolves a deployment version number to its row id without reading the + * version's state. Returns null when the workflow has no such version. + */ +export async function getWorkflowDeploymentVersionId( + workflowId: string, + version: number +): Promise { + const [row] = await db + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.version, version) + ) + ) + .limit(1) + + return row?.id ?? null +} + export interface ListWorkflowVersionsOptions { /** Caps the rows read. Omitted reads every version. */ limit?: number diff --git a/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts new file mode 100644 index 00000000000..b451c7d2de7 --- /dev/null +++ b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts @@ -0,0 +1,483 @@ +import assert from 'node:assert/strict' +import { writeFile } from 'node:fs/promises' +import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import postgres from 'postgres' +import { + type V2ExecuteWorkflowBody, + type V2ResumeWorkflowBody, + v2ExecuteWorkflowDataSchema, + v2WorkflowRunStatusSchema, +} from '@/lib/api/contracts/v2/workflows' +import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' + +/** + * Exercises `run: { source: 'deployment', version }` on the v2 execute route + * against a running local Next app and a disposable database. + * + * Every fixture workflow is deployed twice: version 1 and the active version 2 + * differ only in which block runs, so a run's block outputs and its recorded + * `deployment_version_id` show which graph executed. Before the executor honored + * the pin, a pinned run loaded version 1 for its variables and still executed + * version 2 — the first check fails on that code. + */ +const logger = createLogger('WorkflowPinnedExecuteE2E') +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +/** The first request to each route cold-compiles it under `next dev`. */ +const ROUTE_COMPILE_TIMEOUT_MS = 300_000 +const REQUEST_TIMEOUT_MS = 60_000 +const POLL_ATTEMPTS = 300 +const POLL_INTERVAL_MS = 200 +const startedAt = new Date().toISOString() + +function requiredEnvironment(name: string): string { + const value = process.env[name] + assert(value, `${name} must be explicitly provided`) + return value +} + +const baseUrl = new URL(requiredEnvironment('PINNED_EXECUTE_E2E_BASE_URL')) +const databaseUrl = assertDisposableTestDatabaseUrl( + requiredEnvironment('PINNED_EXECUTE_E2E_DATABASE_URL') +) +const reportPath = requiredEnvironment('PINNED_EXECUTE_E2E_REPORT_PATH') +assert(new Set(['localhost', '127.0.0.1', '[::1]']).has(baseUrl.hostname), 'Use a loopback app') +assert.equal(baseUrl.protocol, 'http:', 'Use a local HTTP app') +assert(!baseUrl.username && !baseUrl.password, 'App URL cannot contain credentials') +assert.equal(baseUrl.pathname, '/', 'App URL must be an origin') +assert(!baseUrl.search && !baseUrl.hash, 'App URL cannot contain a query or fragment') + +const sql = postgres(databaseUrl.toString(), { max: 2 }) +const ownerId = generateId() +const readerId = generateId() +const workspaceId = generateId() +const ownerKey = `sk-sim-fixture-${generateId()}` +const readerKey = `sk-sim-fixture-${generateId()}` +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +/** `status` is null when the request ended without a complete response. */ +const requests: { method: string; path: string; status: number | null; durationMs: number }[] = [] + +interface VersionedFixture { + workflowId: string + start: string + /** Block only version 1 contains, keyed by the name selectors use. */ + one: { id: string; name: string } + /** Block only version 2 (the active one) contains. */ + two: { id: string; name: string } + /** Human pause both versions place before their own block, when present. */ + approval?: string + versionIds: [string, string] +} + +function versionedFixture(withApproval: boolean): VersionedFixture { + return { + workflowId: generateId(), + start: generateId(), + one: { id: generateId(), name: 'VersionOne' }, + two: { id: generateId(), name: 'VersionTwo' }, + ...(withApproval ? { approval: generateId() } : {}), + versionIds: [generateId(), generateId()], + } +} + +const plain = versionedFixture(false) +const paused = versionedFixture(true) +const publicFixture = versionedFixture(false) + +function failureMessage(error: unknown): string { + let message = getErrorMessage(error) + for (const secret of [ownerKey, readerKey]) message = message.replaceAll(secret, '[redacted]') + return truncate(message, 2000) +} + +async function check(name: string, run: () => Promise) { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) }) + logger.info(`PASS ${name}`) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: failureMessage(error), + }) + throw error + } +} + +function record(value: unknown): Record { + assert(isRecordLike(value), 'Expected a JSON object') + return value +} + +function block(id: string, type: string, name: string, index: number, subBlocks = {}) { + return { + id, + type, + name, + enabled: true, + position: { x: index * 300, y: 0 }, + outputs: {}, + subBlocks, + } +} + +const waitSubBlocks = { + timeValue: { id: 'timeValue', type: 'short-input', value: '0.1' }, + timeUnit: { id: 'timeUnit', type: 'dropdown', value: 'seconds' }, + async: { id: 'async', type: 'switch', value: false }, +} + +/** The deployed graph of one version: Start → [Approval →] the version's own block. */ +function deployedState(fixture: VersionedFixture, version: 1 | 2) { + const own = version === 1 ? fixture.one : fixture.two + const chain = [ + block(fixture.start, 'start_trigger', 'Start', 0, { + inputFormat: { id: 'inputFormat', type: 'input-format', value: [] }, + }), + ...(fixture.approval ? [block(fixture.approval, 'human_in_the_loop', 'Approval', 1)] : []), + block(own.id, 'wait', own.name, 2, waitSubBlocks), + ] + return { + blocks: Object.fromEntries(chain.map((entry) => [entry.id, entry])), + edges: chain.slice(1).map((target, index) => ({ + id: generateId(), + source: chain[index].id, + target: target.id, + sourceHandle: 'source', + targetHandle: 'target', + })), + loops: {}, + parallels: {}, + variables: {}, + lastSaved: 0, + } +} + +async function seedFixture( + tx: postgres.TransactionSql, + fixture: VersionedFixture, + isPublicApi: boolean +) { + await tx`insert into workflow (id, user_id, workspace_id, name, last_synced, created_at, updated_at, is_deployed, deployed_at, is_public_api) + values (${fixture.workflowId}, ${ownerId}, ${workspaceId}, ${`Pinned execute fixture ${fixture.workflowId}`}, now(), now(), now(), true, now(), ${isPublicApi})` + for (const version of [1, 2] as const) { + await tx`insert into workflow_deployment_version (id, workflow_id, version, state, is_active, created_by) + values (${fixture.versionIds[version - 1]}, ${fixture.workflowId}, ${version}, ${JSON.stringify(deployedState(fixture, version))}::text::json, ${version === 2}, ${ownerId})` + } +} + +async function seed() { + await sql.begin(async (tx) => { + for (const [id, label] of [ + [ownerId, 'owner'], + [readerId, 'reader'], + ]) { + const email = `${id}@pinned-execute.test` + await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at) + values (${id}, ${`Pinned execute ${label}`}, ${email}, ${email}, true, now(), now())` + await tx`insert into user_stats (id, user_id) values (${generateId()}, ${id})` + } + await tx`insert into project (id, name, owner_id) + values (${workspaceId}, 'Pinned execute project', ${ownerId})` + await tx`insert into workspace (id, project_id, name, owner_id, billed_account_user_id) + values (${workspaceId}, ${workspaceId}, 'Pinned execute fixture', ${ownerId}, ${ownerId})` + for (const [id, role] of [ + [ownerId, 'admin'], + [readerId, 'read'], + ]) { + await tx`insert into permissions (id, user_id, entity_type, entity_id, permission_type) + values (${generateId()}, ${id}, 'workspace', ${workspaceId}, ${role})` + } + for (const [id, key] of [ + [ownerId, ownerKey], + [readerId, readerKey], + ]) { + await tx`insert into api_key (id, user_id, name, key, key_hash, type) + values (${generateId()}, ${id}, 'Pinned execute fixture', ${key}, ${sha256Hex(key)}, 'personal')` + } + await seedFixture(tx, plain, false) + await seedFixture(tx, paused, false) + await seedFixture(tx, publicFixture, true) + }) +} + +function isTimeout(error: unknown): boolean { + return error instanceof DOMException && error.name === 'TimeoutError' +} + +async function request( + method: 'GET' | 'POST', + path: string, + { + body, + key = ownerKey, + expectedStatus = 200, + timeoutMs = REQUEST_TIMEOUT_MS, + }: { + body?: V2ExecuteWorkflowBody | V2ResumeWorkflowBody | Record + key?: string | null + expectedStatus?: number + timeoutMs?: number + } = {} +): Promise> { + const url = new URL(path, baseUrl) + const started = performance.now() + const elapsed = () => Math.round(performance.now() - started) + let status: number | null = null + let text: string + try { + // boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP + const response = await fetch(url, { + method, + redirect: 'error', + signal: AbortSignal.timeout(timeoutMs), + headers: { + accept: 'application/json', + 'x-forwarded-for': '127.0.0.1', + ...(body ? { 'content-type': 'application/json' } : {}), + ...(key ? { 'x-api-key': key } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + text = await readResponseTextWithLimit(response, { + maxBytes: MAX_RESPONSE_BYTES, + label: 'Pinned execute E2E response', + }) + status = response.status + assert.equal(response.headers.get('cache-control'), 'private, no-store') + } catch (error) { + const reason = isTimeout(error) + ? `no complete response within ${timeoutMs / 1000}s` + : getErrorMessage(error) + throw new Error(`${method} ${url.pathname} failed after ${elapsed()} ms: ${reason}`) + } finally { + requests.push({ method, path: url.pathname, status, durationMs: elapsed() }) + } + assert.equal(status, expectedStatus, `${method} ${url.pathname}: ${truncate(text, 500)}`) + return record(JSON.parse(text)) +} + +function execute(fixture: VersionedFixture, body: Record, options = {}) { + return request('POST', `/api/v2/workflows/${fixture.workflowId}/execute`, { + body, + ...options, + }) +} + +async function expectError( + fixture: VersionedFixture, + body: Record, + expectedStatus: number, + code: string, + key: string | null = ownerKey +) { + const error = record((await execute(fixture, body, { expectedStatus, key })).error) + assert.equal(error.code, code, JSON.stringify(error)) +} + +/** + * Selectors resolve against the graph the request loads, so each run selects only + * the block of the version it should execute: an unknown name is a 400 before the + * run. A run that loaded one version and executed the other answers without it. + */ +const selectOwn = (own: { name: string }) => [`${own.name}.status`] + +/** The version id the durable log recorded for a run. */ +async function loggedVersionId(runId: string): Promise { + const [row] = + await sql`select deployment_version_id from workflow_execution_logs where execution_id = ${runId}` + assert(row, `Run ${runId} has no execution log`) + return row.deployment_version_id +} + +async function runStatus(fixture: VersionedFixture, runId: string, query = '') { + return v2WorkflowRunStatusSchema.parse( + ( + await request('GET', `/api/v2/workflows/${fixture.workflowId}/runs/${runId}${query}`, { + timeoutMs: ROUTE_COMPILE_TIMEOUT_MS, + }) + ).data + ) +} + +async function waitForSettled(fixture: VersionedFixture, runId: string) { + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + const status = await runStatus(fixture, runId) + if (status.status !== 'queued' && status.status !== 'running') return status + await sleep(POLL_INTERVAL_MS) + } + throw new Error(`Run ${runId} did not settle`) +} + +try { + await check('seed disposable workspace, deployed versions and credentials', seed) + + await check('the execute route compiles and runs the active version by default', async () => { + const data = v2ExecuteWorkflowDataSchema.parse( + ( + await execute( + plain, + { selectedOutputs: selectOwn(plain.two) }, + { timeoutMs: ROUTE_COMPILE_TIMEOUT_MS } + ) + ).data + ) + assert.equal(data.status, 'completed', JSON.stringify(data.error)) + assert.deepEqual(Object.keys(data.blockOutputs ?? {}), [`${plain.two.name}.status`]) + assert.equal(await loggedVersionId(data.runId), plain.versionIds[1]) + }) + + await check('a synchronous run pinned to version 1 executes version 1', async () => { + const data = v2ExecuteWorkflowDataSchema.parse( + ( + await execute(plain, { + run: { source: 'deployment', version: 1 }, + selectedOutputs: selectOwn(plain.one), + }) + ).data + ) + assert.equal(data.status, 'completed', JSON.stringify(data.error)) + assert.deepEqual(Object.keys(data.blockOutputs ?? {}), [`${plain.one.name}.status`]) + assert.equal(await loggedVersionId(data.runId), plain.versionIds[0]) + const status = await runStatus(plain, data.runId) + assert.equal(status.version, 1) + }) + + await check('an asynchronous run pinned to version 1 executes version 1', async () => { + const receipt = record( + ( + await execute( + plain, + { async: true, run: { source: 'deployment', version: 1 } }, + { expectedStatus: 202 } + ) + ).data + ) + assert.equal(typeof receipt.runId, 'string') + const runId = String(receipt.runId) + const settled = await waitForSettled(plain, runId) + assert.equal(settled.status, 'completed', JSON.stringify(settled.error)) + assert.equal(settled.version, 1) + assert.equal(await loggedVersionId(runId), plain.versionIds[0]) + const withOutputs = await runStatus( + plain, + runId, + `?selectedOutputs=${plain.one.id}.status,${plain.two.id}.status` + ) + assert.deepEqual(Object.keys(withOutputs.blockOutputs ?? {}), [`${plain.one.id}.status`]) + }) + + await check('an unknown version answers 404 and starts no run', async () => { + const before = + await sql`select count(*)::int as n from workflow_execution_logs where workflow_id = ${plain.workflowId}` + await expectError(plain, { run: { source: 'deployment', version: 99 } }, 404, 'NOT_FOUND') + const after = + await sql`select count(*)::int as n from workflow_execution_logs where workflow_id = ${plain.workflowId}` + assert.equal(after[0].n, before[0].n) + }) + + await check('a malformed version answers 400', async () => { + for (const version of [0, -1, 1.5, '1', null]) { + await expectError(plain, { run: { source: 'deployment', version } }, 400, 'BAD_REQUEST') + } + await expectError( + plain, + { run: { source: 'deployment', version: 1, extra: true } }, + 400, + 'BAD_REQUEST' + ) + }) + + await check('a read-role member may run the active version but not pin one', async () => { + const data = v2ExecuteWorkflowDataSchema.parse( + (await execute(plain, {}, { key: readerKey })).data + ) + assert.equal(data.status, 'completed') + const error = record( + ( + await execute( + plain, + { run: { source: 'deployment', version: 1 } }, + { key: readerKey, expectedStatus: 403 } + ) + ).error + ) + assert.equal(error.code, 'FORBIDDEN') + assert.equal(record(error.details).code, 'INSUFFICIENT_WORKSPACE_ROLE') + }) + + await check('an anonymous public-API caller cannot pin a version', async () => { + const data = v2ExecuteWorkflowDataSchema.parse( + (await execute(publicFixture, {}, { key: null })).data + ) + assert.equal(data.status, 'completed') + await expectError( + publicFixture, + { run: { source: 'deployment', version: 1 } }, + 400, + 'BAD_REQUEST', + null + ) + }) + + await check('a pinned run that pauses resumes on the pinned version', async () => { + const data = v2ExecuteWorkflowDataSchema.parse( + (await execute(paused, { run: { source: 'deployment', version: 1 } })).data + ) + assert.equal(data.status, 'paused', JSON.stringify(data.error)) + assert.equal(await loggedVersionId(data.runId), paused.versionIds[0]) + const pausedStatus = await runStatus(paused, data.runId) + assert.equal(pausedStatus.version, 1) + const contextId = pausedStatus.paused?.contextId + assert(contextId, 'The paused run must expose a resumable context') + const resumed = v2ExecuteWorkflowDataSchema.parse( + ( + await request('POST', `/api/v2/workflows/${paused.workflowId}/runs/${data.runId}/resume`, { + body: { contextId, input: { approved: true } }, + timeoutMs: ROUTE_COMPILE_TIMEOUT_MS, + }) + ).data + ) + assert.equal(resumed.status, 'completed', JSON.stringify(resumed.error)) + const settled = await runStatus( + paused, + data.runId, + `?selectedOutputs=${paused.one.id}.status,${paused.two.id}.status` + ) + assert.equal(settled.status, 'completed') + assert.equal(settled.version, 1) + assert.deepEqual(Object.keys(settled.blockOutputs ?? {}), [`${paused.one.id}.status`]) + }) +} catch (error) { + logger.error(failureMessage(error)) + process.exitCode = 1 +} finally { + try { + await check('remove disposable fixtures', async () => { + await sql.begin(async (tx) => { + await tx`delete from workspace where id = ${workspaceId}` + await tx`delete from project where id = ${workspaceId}` + }) + await sql`delete from "user" where id in (${ownerId}, ${readerId})` + }) + } catch (error) { + logger.error(failureMessage(error)) + process.exitCode = 1 + } finally { + await sql.end() + await writeFile( + reportPath, + `${JSON.stringify({ startedAt, finishedAt: new Date().toISOString(), status: process.exitCode ? 'failed' : 'passed', checks, requests }, null, 2)}\n` + ) + } +} diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 992fa1a7246..882df9001ae 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -5545,6 +5545,7 @@ export type ExecuteWorkflowBody = { run?: | { source: 'deployment' + version?: number } | { source: 'manual' @@ -7905,6 +7906,7 @@ type GetWorkflowRunResponseRef2 = { startedAt: string | null endedAt: string | null durationMs: number | null + version: number | null paused: { contextId: string | null pausedAt: string @@ -18558,7 +18560,7 @@ export const V2_OPERATIONS = { run: { kind: 'unknown', describe: - 'Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only; `waitTimeoutSeconds` lets a synchronous one continue in the background.', + 'Workflow state and entry point to execute. Omit for the active deployment; `source: "deployment"` with `version` runs that deployment version. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only; `waitTimeoutSeconds` lets a synchronous one continue in the background.', }, async: { kind: 'boolean', From 8d7aa4f2a418942b57a34ee5542c7bfe18ef4a90 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 12:48:14 -0800 Subject: [PATCH 03/21] feat(api): expose paused run pause points GET /api/v2/workflows/{id}/runs/{runId} lists every point a paused run waits at under paused.points: block, kind, resume state, parallel and loop scope, queue position, the fields a human pause asks for, and its reviewer content. A pause's stored response can carry resolved secrets, so the content and fields are scrubbed through the run's resolved-secret registry when the pause is captured and marked displayScrubbed; points stored before this expose neither. Resume links, API-mode status and headers, and the execution snapshot are never read into the response. --- apps/docs/openapi-v2-workflows.json | 223 ++++++++++++++++- .../[workflowId]/runs/[runId]/route.ts | 7 +- apps/sim/executor/execution/engine.ts | 3 + apps/sim/executor/human-in-the-loop/utils.ts | 74 +++++- apps/sim/executor/types.ts | 20 ++ .../lib/api/contracts/v2/openapi/workflows.ts | 4 +- apps/sim/lib/api/contracts/v2/workflows.ts | 99 ++++++++ .../lib/api/mcp/generated/v2-operations.ts | 4 +- .../application/read-workflow-run.ts | 19 +- .../workflows/executor/execution-status.ts | 146 ++++++++++- .../executor/human-in-the-loop-manager.ts | 20 +- .../executor/pause-persistence.integration.ts | 232 ++++++++++++++++-- .../test-workflow-pinned-execute-e2e.ts | 86 ++++--- packages/sim-cli/src/generated/v2-api.ts | 43 +++- 14 files changed, 914 insertions(+), 66 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index f37787535e9..4850b9b166f 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -4165,7 +4165,7 @@ "get": { "operationId": "getWorkflowRunV2", "summary": "Get workflow run", - "description": "Read current run state with workspace read access. Request final output and files with `includeOutput`, or block outputs with `selectedOutputs`. Organization permission groups can hide output, files, or cost.\n\n`includeFileBase64` inlines file bytes. A file or the total above 16 MiB returns `413` naming a download path; download large files individually. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success.\n\nOAuth scope: `api:read`.", + "description": "Read current run state with workspace read access. Request final output and files with `includeOutput`, or block outputs with `selectedOutputs`. Organization permission groups can hide output, files, or cost. A paused run lists every pause point in `paused.points`, with the fields a human pause asks for and its content with resolved secrets redacted; resume one with Resume workflow run.\n\n`includeFileBase64` inlines file bytes. A file or the total above 16 MiB returns `413` naming a download path; download large files individually. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success.\n\nOAuth scope: `api:read`.", "x-sim-operation": "workflows.runs.read", "x-oauth-scope": "api:read", "tags": ["Workflow Runs"], @@ -4452,7 +4452,7 @@ "post": { "operationId": "resumeWorkflowRunV2", "summary": "Resume workflow run", - "description": "Resume one human-in-the-loop pause with workspace write access. The resumed attempt receives a new run ID and returns a synchronous result or queue receipt. A run that originally streamed resumes asynchronously through v2; this endpoint returns no SSE stream.\n\nOAuth scope: `api:write`.", + "description": "Resume one human-in-the-loop pause with workspace write access, naming a `contextId` from the run's `paused.points`. The resumed attempt receives a new run ID and returns a synchronous result or queue receipt. A run that originally streamed resumes asynchronously through v2; this endpoint returns no SSE stream.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.runs.resume", "x-oauth-scope": "api:write", "tags": ["Workflow Runs"], @@ -14786,6 +14786,215 @@ } ] }, + "WorkflowRunPausePoint": { + "type": "object", + "properties": { + "contextId": { + "type": "string", + "description": "Pause context to name in `contextId` when resuming this point." + }, + "blockId": { + "type": "string", + "description": "Workflow block that paused." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Name of the block in the deployment version the run executed, or null for a manual run or when the version no longer exists." + }, + "kind": { + "type": "string", + "enum": ["human", "time"], + "description": "`human` waits for a resume request; `time` resumes automatically at `resumeAt`." + }, + "status": { + "type": "string", + "enum": ["paused", "queued", "resuming", "resumed", "failed"], + "description": "Resume state of this point: waiting, queued behind another resume, resuming, resumed, or a failed resume attempt." + }, + "registeredAt": { + "type": "string", + "description": "ISO 8601 timestamp when the run paused at this point.", + "format": "date-time" + }, + "resumeAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 automatic-resume timestamp for a time pause, otherwise null.", + "format": "date-time" + }, + "parallel": { + "anyOf": [ + { + "type": "object", + "properties": { + "parallelId": { + "type": "string", + "description": "Parallel block containing the pause." + }, + "branchIndex": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Zero-based branch that paused." + }, + "branchTotal": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Number of branches, or null when unrecorded." + } + }, + "required": ["parallelId", "branchIndex", "branchTotal"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Parallel branch of the pause, or null outside a parallel." + }, + "loop": { + "anyOf": [ + { + "type": "object", + "properties": { + "loopId": { + "type": "string", + "description": "Loop block containing the pause." + }, + "iteration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Zero-based iteration that paused." + } + }, + "required": ["loopId", "iteration"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Loop iteration of the pause, or null outside a loop." + }, + "inputFields": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowRunPauseInputField" + } + }, + { + "type": "null" + } + ], + "description": "Fields a human pause asks for, without default values. Null for a time pause, a pause with no declared form, or one recorded before pause details were captured." + }, + "display": { + "anyOf": [ + { + "description": "Content the pause presents to its reviewer." + }, + { + "type": "null" + } + ], + "description": "Content the pause presents to its reviewer, with the run's resolved secrets redacted. Null for a time pause, content over 64 KiB, or a pause recorded before pause details were captured." + }, + "queuePosition": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ], + "description": "Position among pending resume requests for this run, or null when none waits." + } + }, + "required": [ + "contextId", + "blockId", + "blockName", + "kind", + "status", + "registeredAt", + "resumeAt", + "parallel", + "loop", + "inputFields", + "display", + "queuePosition" + ], + "additionalProperties": false, + "title": "Workflow run pause point", + "description": "One point at which a paused run waits." + }, + "WorkflowRunPauseInputField": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Key to supply this field under in the resume `input`." + }, + "label": { + "type": "string", + "description": "Display label for the field." + }, + "type": { + "type": "string", + "description": "Declared value type, such as `string`, `number`, or `boolean`." + }, + "description": { + "description": "Field help text, when configured.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder text, when configured.", + "type": "string" + }, + "required": { + "type": "boolean", + "description": "Whether the field is marked required." + }, + "options": { + "description": "Selectable options, when the field offers a fixed set.", + "type": "array", + "items": { + "description": "One selectable option." + } + } + }, + "required": ["name", "label", "type", "required"], + "additionalProperties": false, + "title": "Pause input field", + "description": "One field a human pause asks the resumer to fill." + }, "V2RunFile": { "type": "object", "properties": { @@ -14992,6 +15201,13 @@ "resumedCount": { "type": "number", "description": "Number of pause points that have resumed." + }, + "points": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowRunPausePoint" + }, + "description": "Every point the run is paused at, earliest first, at most 100. Resume one with Resume workflow run, naming its `contextId` and supplying `input`." } }, "required": [ @@ -15002,7 +15218,8 @@ "blockedOnBlockId", "automaticResumeWaitingReason", "pausePointCount", - "resumedCount" + "resumedCount", + "points" ], "additionalProperties": false }, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts index 8f3bef26ba7..c09bb08dbcc 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts @@ -46,7 +46,12 @@ export const GET = defineV2JsonRoute({ endedAt: status.endedAt, durationMs: status.totalDurationMs, version: status.deploymentVersion, - paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse(status.paused) : null, + paused: status.paused + ? v2WorkflowRunStatusSchema.shape.paused.parse({ + ...status.paused, + points: status.pausePoints ?? [], + }) + : null, cost: status.cost, error: status.error ? classifyExecutionError(new Error(status.error)) : null, output: status.finalOutput, diff --git a/apps/sim/executor/execution/engine.ts b/apps/sim/executor/execution/engine.ts index ffb20f432f9..092c830e2a6 100644 --- a/apps/sim/executor/execution/engine.ts +++ b/apps/sim/executor/execution/engine.ts @@ -11,6 +11,7 @@ import { serializePauseSnapshot, } from '@/executor/execution/snapshot-serializer' import type { SerializableExecutionState } from '@/executor/execution/types' +import { capturePauseDisplay } from '@/executor/human-in-the-loop/utils' import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import type { ExecutionContext, @@ -651,7 +652,9 @@ export class ExecutionEngine { this.context.metadata.status = 'paused' const snapshotSeed = serializePauseSnapshot(this.context, [], this.dag, this.edgeManager) + const registry = this.context.resolvedSecretTraceRegistry const pausePoints: PausePoint[] = Array.from(this.pausedBlocks.values()).map((pause) => ({ + ...capturePauseDisplay(pause, registry), contextId: pause.contextId, blockId: pause.blockId, response: pause.response, diff --git a/apps/sim/executor/human-in-the-loop/utils.ts b/apps/sim/executor/human-in-the-loop/utils.ts index ef9c8501c7a..79ce35d3f5e 100644 --- a/apps/sim/executor/human-in-the-loop/utils.ts +++ b/apps/sim/executor/human-in-the-loop/utils.ts @@ -1,6 +1,22 @@ -import type { ExecutionContext, LoopPauseScope, ParallelPauseScope } from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { + ExecutionContext, + LoopPauseScope, + ParallelPauseScope, + PauseInputField, + PauseMetadata, + PausePoint, +} from '@/executor/types' +import { projectResolvedSecretModelJsonContent } from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { buildBranchNodeId } from '@/executor/utils/subflow-node-id-codec' +/** Per-point ceiling on captured display content; larger content is withheld, not truncated. */ +const MAX_PAUSE_DISPLAY_BYTES = 64 * 1024 + +/** Response keys that drive resume and rendering rather than describe what the reviewer sees. */ +const PAUSE_RESPONSE_CONTROL_KEYS = new Set(['_resume', 'operation', 'inputFormat', 'submission']) + interface NodeMetadataLike { nodeId: string loopId?: string @@ -59,3 +75,59 @@ export function mapNodeMetadataToPauseScopes( loopScope, } } + +function toPauseInputField(field: unknown): PauseInputField | null { + if (!isRecordLike(field) || typeof field.name !== 'string' || !field.name) return null + return { + name: field.name, + label: typeof field.label === 'string' ? field.label : field.name, + type: typeof field.type === 'string' ? field.type : 'string', + ...(typeof field.description === 'string' ? { description: field.description } : {}), + ...(typeof field.placeholder === 'string' ? { placeholder: field.placeholder } : {}), + required: field.required === true, + ...(Array.isArray(field.options) ? { options: field.options } : {}), + } +} + +/** + * The reviewer-facing view of a pause, scrubbed of every secret the run resolved. + * + * A pause's `response` is persisted as the block produced it, so a reference such as + * `{{API_KEY}}` in its configuration is stored resolved. This projects the parts a caller + * reading the run may see — the content without resume links, control keys, or API-mode status + * and headers, and the input fields without their default values — through the run's committed + * secret matches. An unprovable projection (no registry, an incomplete one, or content over + * {@link MAX_PAUSE_DISPLAY_BYTES}) yields neither. Time pauses carry no reviewer content. + */ +export function capturePauseDisplay( + pause: Pick, + registry: ResolvedSecretTraceRegistry | undefined +): Pick { + const withheld = { displayScrubbed: true, display: null, inputFields: null } + if (pause.pauseKind !== 'human') return withheld + + const data: unknown = isRecordLike(pause.response) ? pause.response.data : undefined + const content = isRecordLike(data) + ? Object.fromEntries( + Object.entries(data).filter(([key]) => !PAUSE_RESPONSE_CONTROL_KEYS.has(key)) + ) + : (data ?? null) + const inputFields = + isRecordLike(data) && Array.isArray(data.inputFormat) + ? data.inputFormat.flatMap((field) => toPauseInputField(field) ?? []) + : null + + const projection = projectResolvedSecretModelJsonContent( + { content, inputFields }, + registry, + MAX_PAUSE_DISPLAY_BYTES + ) + if (!projection.safe || !isRecordLike(projection.value)) return withheld + return { + displayScrubbed: true, + display: projection.value.content ?? null, + inputFields: Array.isArray(projection.value.inputFields) + ? (projection.value.inputFields as PauseInputField[]) + : null, + } +} diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 50e4b108773..6c384a38191 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -97,6 +97,26 @@ export interface PausePoint { } pauseKind: PauseKind resumeAt?: string + /** + * Set when {@link display} and {@link inputFields} were scrubbed of the run's resolved + * secrets as the pause was captured. Points persisted before scrubbing existed lack it, and + * readers must expose neither field for them: their `response` was stored unscrubbed. + */ + displayScrubbed?: boolean + /** Reviewer-facing pause content without resume links or API-mode status and headers. */ + display?: unknown + /** Fields a human pause asks the resumer to fill, without their default values. */ + inputFields?: PauseInputField[] | null +} + +export interface PauseInputField { + name: string + label: string + type: string + description?: string + placeholder?: string + required: boolean + options?: unknown[] } export interface SerializedSnapshot { diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 3c2ec2e81d6..f41a25bfa7d 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -1294,7 +1294,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.readRun, operationId: 'getWorkflowRunV2', summary: 'Get workflow run', - description: `Read current run state with workspace read access. Request final output and files with \`includeOutput\`, or block outputs with \`selectedOutputs\`. Organization permission groups can hide output, files, or cost.\n\n\`includeFileBase64\` inlines file bytes. A file or the total above 16 MiB returns \`413\` naming a download path; download large files individually. ${HEAD_MIRRORS_GET}`, + description: `Read current run state with workspace read access. Request final output and files with \`includeOutput\`, or block outputs with \`selectedOutputs\`. Organization permission groups can hide output, files, or cost. A paused run lists every pause point in \`paused.points\`, with the fields a human pause asks for and its content with resolved secrets redacted; resume one with Resume workflow run.\n\n\`includeFileBase64\` inlines file bytes. A file or the total above 16 MiB returns \`413\` naming a download path; download large files individually. ${HEAD_MIRRORS_GET}`, errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow run status.'), }), @@ -1364,7 +1364,7 @@ const declaredRoutes = [ operationId: 'resumeWorkflowRunV2', summary: 'Resume workflow run', description: - 'Resume one human-in-the-loop pause with workspace write access. The resumed attempt receives a new run ID and returns a synchronous result or queue receipt. A run that originally streamed resumes asynchronously through v2; this endpoint returns no SSE stream.', + "Resume one human-in-the-loop pause with workspace write access, naming a `contextId` from the run's `paused.points`. The resumed attempt receives a new run ID and returns a synchronous result or queue receipt. A run that originally streamed resumes asynchronously through v2; this endpoint returns no SSE stream.", errors: [...RESOURCE_ERRORS, 'UsageLimitExceeded', 'Conflict', 'PayloadTooLarge'], success: { byStatus: { diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 20f25451e18..5d6c8e72b02 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1901,6 +1901,98 @@ export const v2RunFileSchema = z }) export type V2RunFile = z.output +const v2PauseInputFieldSchema = z + .object({ + name: z.string().describe('Key to supply this field under in the resume `input`.'), + label: z.string().describe('Display label for the field.'), + type: z.string().describe('Declared value type, such as `string`, `number`, or `boolean`.'), + description: z.string().optional().describe('Field help text, when configured.'), + placeholder: z.string().optional().describe('Placeholder text, when configured.'), + required: z.boolean().describe('Whether the field is marked required.'), + options: z + .array(z.unknown().describe('One selectable option.')) + .optional() + .describe('Selectable options, when the field offers a fixed set.'), + }) + .meta({ + id: 'WorkflowRunPauseInputField', + title: 'Pause input field', + description: 'One field a human pause asks the resumer to fill.', + }) + +const v2WorkflowRunPausePointSchema = z + .object({ + contextId: z + .string() + .describe('Pause context to name in `contextId` when resuming this point.'), + blockId: z.string().describe('Workflow block that paused.'), + blockName: z + .string() + .nullable() + .describe( + 'Name of the block in the deployment version the run executed, or null for a manual run or when the version no longer exists.' + ), + kind: z + .enum(['human', 'time']) + .describe('`human` waits for a resume request; `time` resumes automatically at `resumeAt`.'), + status: z + .enum(['paused', 'queued', 'resuming', 'resumed', 'failed']) + .describe( + 'Resume state of this point: waiting, queued behind another resume, resuming, resumed, or a failed resume attempt.' + ), + registeredAt: z + .string() + .describe('ISO 8601 timestamp when the run paused at this point.') + .meta({ format: 'date-time' }), + resumeAt: z + .string() + .nullable() + .describe('ISO 8601 automatic-resume timestamp for a time pause, otherwise null.') + .meta({ format: 'date-time' }), + parallel: z + .object({ + parallelId: z.string().describe('Parallel block containing the pause.'), + branchIndex: z.number().int().describe('Zero-based branch that paused.'), + branchTotal: z + .number() + .int() + .nullable() + .describe('Number of branches, or null when unrecorded.'), + }) + .nullable() + .describe('Parallel branch of the pause, or null outside a parallel.'), + loop: z + .object({ + loopId: z.string().describe('Loop block containing the pause.'), + iteration: z.number().int().describe('Zero-based iteration that paused.'), + }) + .nullable() + .describe('Loop iteration of the pause, or null outside a loop.'), + inputFields: z + .array(v2PauseInputFieldSchema) + .nullable() + .describe( + 'Fields a human pause asks for, without default values. Null for a time pause, a pause with no declared form, or one recorded before pause details were captured.' + ), + display: z + .unknown() + .describe('Content the pause presents to its reviewer.') + .nullable() + .describe( + "Content the pause presents to its reviewer, with the run's resolved secrets redacted. Null for a time pause, content over 64 KiB, or a pause recorded before pause details were captured." + ), + queuePosition: z + .number() + .int() + .nullable() + .describe('Position among pending resume requests for this run, or null when none waits.'), + }) + .meta({ + id: 'WorkflowRunPausePoint', + title: 'Workflow run pause point', + description: 'One point at which a paused run waits.', + }) + /** * The polled run resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 @@ -1953,6 +2045,13 @@ export const v2WorkflowRunStatusSchema = z ), paused: workflowExecutionPausedDetailSchema .omit({ pausedExecutionId: true }) + .extend({ + points: z + .array(v2WorkflowRunPausePointSchema) + .describe( + 'Every point the run is paused at, earliest first, at most 100. Resume one with Resume workflow run, naming its `contextId` and supplying `input`.' + ), + }) .nullable() .describe('Current pause details, or null when the run is not paused.'), cost: z diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index fc76e1ba567..3159800dc1b 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -1649,7 +1649,7 @@ export const V2_MCP_OPERATIONS = { contract: v2GetWorkflowRunContract, summary: 'Get workflow run', description: - 'Read current run state with workspace read access. Request final output and files with `includeOutput`, or block outputs with `selectedOutputs`. Organization permission groups can hide output, files, or cost.\n\n`includeFileBase64` inlines file bytes. A file or the total above 16 MiB returns `413` naming a download path; download large files individually. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success.\n\nOAuth scope: `api:read`.', + 'Read current run state with workspace read access. Request final output and files with `includeOutput`, or block outputs with `selectedOutputs`. Organization permission groups can hide output, files, or cost. A paused run lists every pause point in `paused.points`, with the fields a human pause asks for and its content with resolved secrets redacted; resume one with Resume workflow run.\n\n`includeFileBase64` inlines file bytes. A file or the total above 16 MiB returns `413` naming a download path; download large files individually. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/route').then((route) => route.GET), }, @@ -2553,7 +2553,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ResumeWorkflowContract, summary: 'Resume workflow run', description: - 'Resume one human-in-the-loop pause with workspace write access. The resumed attempt receives a new run ID and returns a synchronous result or queue receipt. A run that originally streamed resumes asynchronously through v2; this endpoint returns no SSE stream.\n\nOAuth scope: `api:write`.', + "Resume one human-in-the-loop pause with workspace write access, naming a `contextId` from the run's `paused.points`. The resumed attempt receives a new run ID and returns a synchronous result or queue receipt. A run that originally streamed resumes asynchronously through v2; this endpoint returns no SSE stream.\n\nOAuth scope: `api:write`.", handler: () => import('@/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route').then( (route) => route.POST diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index ad29de11189..a0b3da41981 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -15,7 +15,11 @@ import { getWorkflowRunFiles, type WorkflowRunFileDescriptor, } from '@/lib/workflows/executor/execution-run-files' -import { getProjectedWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { + getProjectedWorkflowExecutionStatus, + type PausedRunPoint, + readPausedRunPoints, +} from '@/lib/workflows/executor/execution-status' /** * Selectors this resource can never answer, so the caller hears about them. @@ -148,7 +152,18 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ : [] } - return { ...status, files } + /** + * Every pause point of a paused run, so a caller can resume any of them by + * `contextId`, not only the earliest one `paused` summarizes. + */ + const pausePoints: PausedRunPoint[] | null = status.paused + ? ((await readPausedRunPoints({ + workflowId: context.workflowId, + executionId: context.runId, + })) ?? []) + : null + + return { ...status, files, pausePoints } } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { throw new OrchestrationError('conflict', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 1141c96ecde..d000aec2ef4 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -5,6 +5,7 @@ import { workflowDeploymentVersion, workflowExecutionLogs, } from '@sim/db/schema' +import { compareStrings } from '@sim/utils/string' import { and, eq, inArray, sql } from 'drizzle-orm' import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' import { getJobQueue } from '@/lib/core/async-jobs' @@ -20,8 +21,10 @@ import { WORKFLOW_EXECUTION_JOB_ID_PREFIX, } from '@/lib/workflows/executor/execution-job-ids' import { normalizePausePoints } from '@/lib/workflows/executor/execution-state' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' -import type { PausePoint } from '@/executor/types' +import type { PauseInputField, PausePoint, ResumeStatus } from '@/executor/types' +import { extractBaseBlockId } from '@/executor/utils/subflow-node-id-codec' /** * Reads a single execution's status resource — the log row, the paused-state @@ -504,3 +507,144 @@ async function readWorkflowExecutionStatus( return { ...resource, error, finalOutput, blockOutputs } } + +/** Most pause points a run detail lists; a run over it lists its earliest registered points. */ +const MAX_PAUSED_RUN_POINTS = 100 + +/** One pause point of a paused run, as a caller reading the run may see it. */ +export interface PausedRunPoint { + contextId: string + blockId: string + blockName: string | null + kind: 'human' | 'time' + status: ResumeStatus + registeredAt: string + resumeAt: string | null + parallel: { parallelId: string; branchIndex: number; branchTotal: number | null } | null + loop: { loopId: string; iteration: number } | null + inputFields: PauseInputField[] | null + display: unknown + queuePosition: number | null +} + +/** + * Names of `blockIds` in the deployment version the run executed, read inside the + * database so the version's state never leaves it. Empty for a run with no recorded + * version (a manual run) or whose version no longer exists. + */ +async function readRunBlockNames( + workflowId: string, + executionId: string, + blockIds: string[] +): Promise> { + if (blockIds.length === 0) return new Map() + const [row] = await db + .select({ + names: sql | null>`( + select jsonb_object_agg(b.key, b.value ->> 'name') + from json_each(${workflowDeploymentVersion.state} -> 'blocks') as b + where b.key in (${sql.join( + blockIds.map((id) => sql`${id}`), + sql`, ` + )}) + )`, + }) + .from(workflowExecutionLogs) + .innerJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + const names = new Map() + for (const [id, name] of Object.entries(row?.names ?? {})) { + if (typeof name === 'string' && name) names.set(id, name) + } + return names +} + +/** + * The pause points of a paused run, earliest registered first and capped at + * {@link MAX_PAUSED_RUN_POINTS}, or null when the run has no paused record. + * + * Reads only `pause_points`, never the execution snapshot. `display` and + * `inputFields` come only from points scrubbed of the run's resolved secrets when + * they were captured; an older point's stored response was never scrubbed, so both + * are null for it. Resume links and API-mode response status and headers are never + * part of a point. + */ +export async function readPausedRunPoints(input: { + workflowId: string + executionId: string +}): Promise { + const { workflowId, executionId } = input + const [row] = await db + .select({ pausePoints: pausedExecutions.pausePoints }) + .from(pausedExecutions) + .where( + and( + eq(pausedExecutions.executionId, executionId), + eq(pausedExecutions.workflowId, workflowId) + ) + ) + .limit(1) + if (!row) return null + + const pendingResumes = await db + .select({ + contextId: resumeQueue.contextId, + status: resumeQueue.status, + queuedAt: resumeQueue.queuedAt, + }) + .from(resumeQueue) + .where(and(eq(resumeQueue.parentExecutionId, executionId), eq(resumeQueue.status, 'pending'))) + .orderBy(resumeQueue.queuedAt) + .limit(MAX_PAUSED_RUN_POINTS) + const queuePositions = PauseResumeManager.computeQueuePositions( + pendingResumes.map((entry) => ({ + contextId: entry.contextId, + status: entry.status, + queuedAt: entry.queuedAt?.toISOString() ?? null, + })) + ) + + const points = PauseResumeManager.mapPausePoints(row.pausePoints, queuePositions) + .sort((a, b) => compareStrings(a.registeredAt ?? '', b.registeredAt ?? '')) + .slice(0, MAX_PAUSED_RUN_POINTS) + const baseBlockId = (point: PausePoint) => extractBaseBlockId(point.blockId ?? point.contextId) + const names = await readRunBlockNames(workflowId, executionId, [ + ...new Set(points.map(baseBlockId)), + ]) + + return points.map((point) => { + const blockId = baseBlockId(point) + const scrubbed = point.displayScrubbed === true + return { + contextId: point.contextId, + blockId, + blockName: names.get(blockId) ?? null, + kind: point.pauseKind === 'time' ? 'time' : 'human', + status: point.resumeStatus, + registeredAt: point.registeredAt, + resumeAt: point.resumeAt ?? null, + parallel: point.parallelScope + ? { + parallelId: point.parallelScope.parallelId, + branchIndex: point.parallelScope.branchIndex, + branchTotal: point.parallelScope.branchTotal ?? null, + } + : null, + loop: point.loopScope + ? { loopId: point.loopScope.loopId, iteration: point.loopScope.iteration } + : null, + inputFields: scrubbed ? (point.inputFields ?? null) : null, + display: scrubbed ? (point.display ?? null) : null, + queuePosition: point.queuePosition ?? null, + } + }) +} diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 7687162d637..e8e57c30f61 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -605,6 +605,13 @@ export class PauseResumeManager { resumeLinks: point.resumeLinks, pauseKind: point.pauseKind, resumeAt: point.resumeAt, + ...(point.displayScrubbed + ? { + displayScrubbed: true, + display: point.display ?? null, + inputFields: point.inputFields ?? null, + } + : {}), } return acc }, {}) @@ -3791,7 +3798,7 @@ export class PauseResumeManager { } } - private static mapPausePoints( + static mapPausePoints( pausePoints: unknown, queuePositions?: Map, latestEntries?: Map @@ -3833,14 +3840,21 @@ export class PauseResumeManager { resumeLinks, pauseKind: point.pauseKind ?? 'human', resumeAt: point.resumeAt, + ...(point.displayScrubbed === true + ? { + displayScrubbed: true, + display: point.display ?? null, + inputFields: point.inputFields ?? null, + } + : {}), queuePosition, latestResumeEntry: latestEntry ?? null, } }) } - private static computeQueuePositions( - queueEntries: ResumeQueueEntrySummary[] + static computeQueuePositions( + queueEntries: Pick[] ): Map { const pendingEntries = queueEntries .filter((entry) => entry.status === 'pending') diff --git a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts index 90abb753337..7b421a60867 100644 --- a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts +++ b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts @@ -1,12 +1,14 @@ /** * Pause publication against real PostgreSQL: a paused run becomes resumable only once its - * log has been finalized out of `running`, so an immediate resume finds a claimable log. + * log has been finalized out of `running`, so an immediate resume finds a claimable log; and + * the pause points a run detail reads back from what was published. */ import { db } from '@sim/db' import { pausedExecutions, user, workflow, + workflowDeploymentVersion, workflowExecutionLogs, workflowExecutionSnapshots, workspace, @@ -22,9 +24,13 @@ import { } from '@/lib/billing/core/attribution' import { LoggingSession } from '@/lib/logs/execution/logging-session' import type { WorkflowState } from '@/lib/logs/types' +import { readPausedRunPoints } from '@/lib/workflows/executor/execution-status' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' -import type { ExecutionResult } from '@/executor/types' +import { capturePauseDisplay } from '@/executor/human-in-the-loop/utils' +import type { ExecutionResult, PausePoint } from '@/executor/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { buildBranchNodeId } from '@/executor/utils/subflow-node-id-codec' const ids = { owner: `pause-publish-owner-${generateId()}`, @@ -51,25 +57,29 @@ const workflowState: WorkflowState = { parallels: {}, } +/** A marker only the execution snapshot carries, so a read that leaks the snapshot shows it. */ +const SNAPSHOT_SENTINEL = `snapshot-only-${generateId()}` + function pausedResult( executionId: string, - billingAttribution: BillingAttributionSnapshot + billingAttribution: BillingAttributionSnapshot, + pausePoints: PausePoint[] = [ + { + contextId: CONTEXT_ID, + blockId: CONTEXT_ID, + response: {}, + registeredAt: new Date().toISOString(), + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + }, + ] ): ExecutionResult { return { success: true, output: {}, status: 'paused', - pausePoints: [ - { - contextId: CONTEXT_ID, - blockId: CONTEXT_ID, - response: {}, - registeredAt: new Date().toISOString(), - resumeStatus: 'paused', - snapshotReady: true, - pauseKind: 'human', - }, - ], + pausePoints, snapshotSeed: { snapshot: JSON.stringify({ metadata: { @@ -79,6 +89,7 @@ function pausedResult( userId: ids.owner, billingAttribution, }, + state: { sentinel: SNAPSHOT_SENTINEL }, }), triggerIds: [], }, @@ -86,7 +97,7 @@ function pausedResult( } /** Starts a run whose log is `running`, as the core leaves it when execution returns. */ -async function startRun() { +async function startRun(pausePoints?: PausePoint[]) { const executionId = generateId() const billingAttribution = await resolveBillingAttribution({ actorUserId: ids.owner, @@ -99,7 +110,11 @@ async function startRun() { billingAttribution, workflowState, }) - return { executionId, loggingSession, result: pausedResult(executionId, billingAttribution) } + return { + executionId, + loggingSession, + result: pausedResult(executionId, billingAttribution, pausePoints), + } } async function logStatus(executionId: string) { @@ -110,11 +125,11 @@ async function logStatus(executionId: string) { return row?.status } -function resume(executionId: string) { +function resume(executionId: string, contextId = CONTEXT_ID) { return PauseResumeManager.enqueueOrStartResume({ executionId, workflowId: ids.workflow, - contextId: CONTEXT_ID, + contextId, resumeInput: {}, userId: ids.owner, }) @@ -151,6 +166,9 @@ afterAll(async () => { // Deleting a paused execution cascades to its resume queue entries. await db.delete(pausedExecutions).where(eq(pausedExecutions.workflowId, ids.workflow)) await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workflowId, ids.workflow)) + await db + .delete(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.workflowId, ids.workflow)) await db .delete(workflowExecutionSnapshots) .where(eq(workflowExecutionSnapshots.workflowId, ids.workflow)) @@ -215,3 +233,181 @@ describe('handlePostExecutionPauseState', () => { }) }) }) + +const SECRET = `resolved-secret-${generateId()}` +const PARALLEL_ID = 'review-parallel' +const BRANCH_IDS = [0, 1].map((index) => buildBranchNodeId(CONTEXT_ID, index)) + +/** A run registry that has resolved {@link SECRET} as `{{API_KEY}}`, as a block input would. */ +function registryWithResolvedSecret() { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: SECRET, encryptedValue: 'ciphertext' }, + ]) + registry.recordResolved('API_KEY', SECRET) + return registry +} + +/** The pause a human-in-the-loop block in a parallel branch records, with a secret in its content. */ +function humanBranchPoint( + branchIndex: number, + registry: ResolvedSecretTraceRegistry | undefined +): PausePoint { + const contextId = BRANCH_IDS[branchIndex] + const response = { + data: { + operation: 'human', + responseStructure: [{ name: 'summary', type: 'string', value: `Token is ${SECRET}` }], + inputFormat: [{ id: 'f1', name: 'approved', type: 'boolean', required: true, value: SECRET }], + submission: null, + _resume: { apiUrl: 'https://resume.invalid/api', uiUrl: 'https://resume.invalid/ui' }, + }, + status: 200, + headers: { 'X-Pause-Header': SECRET }, + } + return { + ...capturePauseDisplay({ pauseKind: 'human', response }, registry), + contextId, + blockId: contextId, + response, + registeredAt: new Date(Date.UTC(2026, 0, 1, 0, 0, branchIndex)).toISOString(), + resumeStatus: 'paused', + snapshotReady: true, + parallelScope: { parallelId: PARALLEL_ID, branchIndex, branchTotal: 2 }, + pauseKind: 'human', + } +} + +/** Publishes a paused run the way the core does once its log is finalized. */ +async function publishPausedRun(points: PausePoint[]) { + const { executionId, loggingSession, result } = await startRun(points) + loggingSession.setPostExecutionPromise(loggingSession.safeCompleteWithPause({ traceSpans: [] })) + await handlePostExecutionPauseState({ + result, + workflowId: ids.workflow, + executionId, + loggingSession, + }) + return executionId +} + +async function storedPausePoints(executionId: string): Promise> { + const [row] = await db + .select({ pausePoints: pausedExecutions.pausePoints }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + return row.pausePoints as Record +} + +describe('readPausedRunPoints', () => { + it('lists every parallel branch with its scope, queue position, and deployed block name', async () => { + const registry = registryWithResolvedSecret() + const executionId = await publishPausedRun([ + humanBranchPoint(0, registry), + humanBranchPoint(1, registry), + ]) + const versionId = generateId() + await db.insert(workflowDeploymentVersion).values({ + id: versionId, + workflowId: ids.workflow, + version: 1, + state: { blocks: { [CONTEXT_ID]: { id: CONTEXT_ID, name: 'Approval' } } }, + isActive: false, + }) + await db + .update(workflowExecutionLogs) + .set({ deploymentVersionId: versionId }) + .where(eq(workflowExecutionLogs.executionId, executionId)) + + await expect(resume(executionId, BRANCH_IDS[0])).resolves.toMatchObject({ + status: 'starting', + }) + await expect(resume(executionId, BRANCH_IDS[1])).resolves.toMatchObject({ + status: 'queued', + queuePosition: 1, + }) + + const points = await readPausedRunPoints({ workflowId: ids.workflow, executionId }) + + expect(points?.map(({ display, inputFields, ...point }) => point)).toEqual( + [0, 1].map((branchIndex) => ({ + contextId: BRANCH_IDS[branchIndex], + blockId: CONTEXT_ID, + blockName: 'Approval', + kind: 'human', + status: branchIndex === 0 ? 'resuming' : 'queued', + registeredAt: new Date(Date.UTC(2026, 0, 1, 0, 0, branchIndex)).toISOString(), + resumeAt: null, + parallel: { parallelId: PARALLEL_ID, branchIndex, branchTotal: 2 }, + loop: null, + queuePosition: branchIndex === 0 ? null : 1, + })) + ) + }) + + it('stores and returns pause content with resolved secrets redacted and control data withheld', async () => { + const executionId = await publishPausedRun([humanBranchPoint(0, registryWithResolvedSecret())]) + + const stored = await storedPausePoints(executionId) + expect(stored[BRANCH_IDS[0]]).toMatchObject({ displayScrubbed: true }) + expect(JSON.stringify(stored[BRANCH_IDS[0]].display)).not.toContain(SECRET) + + const [point] = (await readPausedRunPoints({ workflowId: ids.workflow, executionId })) ?? [] + expect(point.display).toEqual({ + responseStructure: [{ name: 'summary', type: 'string', value: 'Token is {{API_KEY}}' }], + }) + expect(point.inputFields).toEqual([ + { name: 'approved', label: 'approved', type: 'boolean', required: true }, + ]) + const body = JSON.stringify(point) + for (const withheld of [SECRET, SNAPSHOT_SENTINEL, 'resume.invalid', 'X-Pause-Header']) { + expect(body).not.toContain(withheld) + } + }) + + it('withholds content it cannot prove free of secrets', async () => { + const executionId = await publishPausedRun([humanBranchPoint(0, undefined)]) + + const [point] = (await readPausedRunPoints({ workflowId: ids.workflow, executionId })) ?? [] + expect(point).toMatchObject({ display: null, inputFields: null, blockName: null }) + }) + + it('exposes no content from a point persisted before pause content was scrubbed', async () => { + const { displayScrubbed, display, inputFields, ...legacy } = humanBranchPoint(0, undefined) + legacy.response.data.responseStructure[0].value = SECRET + const executionId = await publishPausedRun([legacy]) + + expect((await storedPausePoints(executionId))[BRANCH_IDS[0]]).not.toHaveProperty( + 'displayScrubbed' + ) + const [point] = (await readPausedRunPoints({ workflowId: ids.workflow, executionId })) ?? [] + expect(point).toMatchObject({ display: null, inputFields: null }) + expect(JSON.stringify(point)).not.toContain(SECRET) + }) + + it('reports a time pause with its resume time and no input fields', async () => { + const resumeAt = new Date(Date.now() + 60_000).toISOString() + const executionId = await publishPausedRun([ + { + ...capturePauseDisplay({ pauseKind: 'time', response: {} }, registryWithResolvedSecret()), + contextId: 'wait', + blockId: 'wait', + response: {}, + registeredAt: new Date().toISOString(), + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'time', + resumeAt, + }, + ]) + + const [point] = (await readPausedRunPoints({ workflowId: ids.workflow, executionId })) ?? [] + expect(point).toMatchObject({ + kind: 'time', + resumeAt, + inputFields: null, + display: null, + parallel: null, + loop: null, + }) + }) +}) diff --git a/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts index b451c7d2de7..f636928bb33 100644 --- a/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts +++ b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts @@ -430,34 +430,64 @@ try { ) }) - await check('a pinned run that pauses resumes on the pinned version', async () => { - const data = v2ExecuteWorkflowDataSchema.parse( - (await execute(paused, { run: { source: 'deployment', version: 1 } })).data - ) - assert.equal(data.status, 'paused', JSON.stringify(data.error)) - assert.equal(await loggedVersionId(data.runId), paused.versionIds[0]) - const pausedStatus = await runStatus(paused, data.runId) - assert.equal(pausedStatus.version, 1) - const contextId = pausedStatus.paused?.contextId - assert(contextId, 'The paused run must expose a resumable context') - const resumed = v2ExecuteWorkflowDataSchema.parse( - ( - await request('POST', `/api/v2/workflows/${paused.workflowId}/runs/${data.runId}/resume`, { - body: { contextId, input: { approved: true } }, - timeoutMs: ROUTE_COMPILE_TIMEOUT_MS, - }) - ).data - ) - assert.equal(resumed.status, 'completed', JSON.stringify(resumed.error)) - const settled = await runStatus( - paused, - data.runId, - `?selectedOutputs=${paused.one.id}.status,${paused.two.id}.status` - ) - assert.equal(settled.status, 'completed') - assert.equal(settled.version, 1) - assert.deepEqual(Object.keys(settled.blockOutputs ?? {}), [`${paused.one.id}.status`]) - }) + await check( + 'a pinned run that pauses lists its pause point and resumes on the pinned version', + async () => { + const data = v2ExecuteWorkflowDataSchema.parse( + (await execute(paused, { run: { source: 'deployment', version: 1 } })).data + ) + assert.equal(data.status, 'paused', JSON.stringify(data.error)) + assert.equal(await loggedVersionId(data.runId), paused.versionIds[0]) + const pausedStatus = await runStatus(paused, data.runId) + assert.equal(pausedStatus.version, 1) + const contextId = pausedStatus.paused?.contextId + assert(contextId, 'The paused run must expose a resumable context') + const points = pausedStatus.paused?.points ?? [] + assert.equal(points.length, 1) + assert.deepEqual( + { + contextId: points[0].contextId, + blockId: points[0].blockId, + blockName: points[0].blockName, + kind: points[0].kind, + status: points[0].status, + parallel: points[0].parallel, + loop: points[0].loop, + }, + { + contextId, + blockId: paused.approval, + blockName: 'Approval', + kind: 'human', + status: 'paused', + parallel: null, + loop: null, + } + ) + assert(Array.isArray(points[0].inputFields), 'A human pause captured now lists its fields') + const resumed = v2ExecuteWorkflowDataSchema.parse( + ( + await request( + 'POST', + `/api/v2/workflows/${paused.workflowId}/runs/${data.runId}/resume`, + { + body: { contextId, input: { approved: true } }, + timeoutMs: ROUTE_COMPILE_TIMEOUT_MS, + } + ) + ).data + ) + assert.equal(resumed.status, 'completed', JSON.stringify(resumed.error)) + const settled = await runStatus( + paused, + data.runId, + `?selectedOutputs=${paused.one.id}.status,${paused.two.id}.status` + ) + assert.equal(settled.status, 'completed') + assert.equal(settled.version, 1) + assert.deepEqual(Object.keys(settled.blockOutputs ?? {}), [`${paused.one.id}.status`]) + } + ) } catch (error) { logger.error(failureMessage(error)) process.exitCode = 1 diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 882df9001ae..7153d859606 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -7867,6 +7867,38 @@ export type GetWorkflowRunQuery = { } type GetWorkflowRunResponseRef0 = { + contextId: string + blockId: string + blockName: string | null + kind: 'human' | 'time' + status: 'paused' | 'queued' | 'resuming' | 'resumed' | 'failed' + registeredAt: string + resumeAt: string | null + parallel: { + parallelId: string + branchIndex: number + branchTotal: number | null + } | null + loop: { + loopId: string + iteration: number + } | null + inputFields: Array | null + display: unknown | null + queuePosition: number | null +} + +type GetWorkflowRunResponseRef1 = { + name: string + label: string + type: string + description?: string + placeholder?: string + required: boolean + options?: Array +} + +type GetWorkflowRunResponseRef2 = { message: string code: | 'TIMEOUT' @@ -7881,7 +7913,7 @@ type GetWorkflowRunResponseRef0 = { blockType?: string } -type GetWorkflowRunResponseRef1 = { +type GetWorkflowRunResponseRef3 = { id: string name: string size: number @@ -7890,7 +7922,7 @@ type GetWorkflowRunResponseRef1 = { base64: string | null } -type GetWorkflowRunResponseRef2 = { +type GetWorkflowRunResponseRef4 = { runId: string workflowId: string status: @@ -7916,18 +7948,19 @@ type GetWorkflowRunResponseRef2 = { automaticResumeWaitingReason: string | null pausePointCount: number resumedCount: number + points: Array } | null cost: { total: number } | null - error: GetWorkflowRunResponseRef0 | null + error: GetWorkflowRunResponseRef2 | null output: unknown | null blockOutputs: Record | null - files: Array | null + files: Array | null } export type GetWorkflowRunResponse = { - data: GetWorkflowRunResponseRef2 + data: GetWorkflowRunResponseRef4 } /** `GET /api/v2/workflows/[workflowId]/state` */ From ad2187b6668390cc5804a07321b69b78c2dcf662 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 12:58:23 -0800 Subject: [PATCH 04/21] fix(api): type the deployed-run helper's principal and keep the status field optional The shared executeDeployedWorkflow helper took a broad Principal, which no longer satisfies executeWorkflowService's WorkflowExecutionPrincipal once the use case's narrowed principal is widened by the extraction. The internal run status response's new deploymentVersion field is optional so a browser bundle deployed ahead of its server still parses the response. --- .../api/v2/workflows/[workflowId]/runs/[runId]/route.ts | 2 +- apps/sim/lib/api/contracts/workflows.ts | 7 +++++-- apps/sim/lib/workflows/application/execute-workflow.ts | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts index c09bb08dbcc..9bbf5c353a6 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.ts @@ -45,7 +45,7 @@ export const GET = defineV2JsonRoute({ startedAt: status.startedAt, endedAt: status.endedAt, durationMs: status.totalDurationMs, - version: status.deploymentVersion, + version: status.deploymentVersion ?? null, paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse({ ...status.paused, diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 8b33c499afb..b5eabc856fa 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -612,8 +612,11 @@ const workflowExecutionStatusResponseSchema = z.object({ startedAt: z.string(), endedAt: z.string().nullable(), totalDurationMs: z.number().nullable(), - /** Deployment version number the run executed; null for draft or not-yet-recorded runs. */ - deploymentVersion: z.number().int().nullable(), + /** + * Deployment version number the run executed; null for draft or not-yet-recorded runs. + * Optional so a client deployed ahead of the server still parses its response. + */ + deploymentVersion: z.number().int().nullable().optional(), paused: workflowExecutionPausedDetailSchema.nullable(), cost: z.object({ total: z.number() }).nullable(), error: z.string().nullable(), diff --git a/apps/sim/lib/workflows/application/execute-workflow.ts b/apps/sim/lib/workflows/application/execute-workflow.ts index 817cfb9d8aa..e5f744fd607 100644 --- a/apps/sim/lib/workflows/application/execute-workflow.ts +++ b/apps/sim/lib/workflows/application/execute-workflow.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import type { Principal, WorkflowExecutionPrincipal } from '@sim/auth/principal' import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { @@ -45,7 +45,7 @@ function authenticatesExecutionCredentials(principal: Principal): boolean { } async function executeDeployedWorkflow( - principal: Principal, + principal: WorkflowExecutionPrincipal, context: ActiveWorkflowApplicationContext, input: ExecuteWorkflowInput, deploymentVersionId?: string From b02576048b256e8b66dda464c6f3ef39bfc5c112 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 12:58:34 -0800 Subject: [PATCH 05/21] test(execution): prove pinned deployed runs against real Postgres Runs a workflow deployed twice through the execute-version use case: a sync and an async run pinned to version 1 execute version 1 while version 2 is active and record it on the log, the queued payload carries the pin, and an unknown version is not_found before any run starts. Both pinned cases fail with the executor change reverted. --- .../execute-workflow-version.integration.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 apps/sim/lib/workflows/application/execute-workflow-version.integration.ts diff --git a/apps/sim/lib/workflows/application/execute-workflow-version.integration.ts b/apps/sim/lib/workflows/application/execute-workflow-version.integration.ts new file mode 100644 index 00000000000..7c4db44e667 --- /dev/null +++ b/apps/sim/lib/workflows/application/execute-workflow-version.integration.ts @@ -0,0 +1,216 @@ +/** + * Deployed runs against real PostgreSQL through the execute use cases: a pinned run executes + * the version it named rather than the active one, on the sync and the async path. + */ +import { db } from '@sim/db' +import { + asyncJobs, + permissions, + user, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, + workspace, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { executeWorkflowVersionOperation } from '@/lib/workflows/application/execute-workflow' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' + +const ownerId = generateId() +const workspaceId = generateId() +const workflowId = generateId() +const principal = createSessionPrincipal({ userId: ownerId, sessionId: generateId() }) +const versionIds = { one: generateId(), two: generateId() } +const jobIds: string[] = [] + +/** One variable whose value differs between the deployed versions and the editor's draft. */ +function variables(greeting: string) { + return { + greeting: { id: 'greeting', workflowId, name: 'greeting', type: 'plain', value: greeting }, + } +} + +/** Start into a Response that echoes the version label and the `greeting` variable. */ +function deployedState(label: string, greeting: string) { + return { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + enabled: true, + position: { x: 0, y: 0 }, + outputs: {}, + subBlocks: {}, + }, + respond: { + id: 'respond', + type: 'response', + name: 'Respond', + enabled: true, + position: { x: 300, y: 0 }, + outputs: {}, + subBlocks: { + dataMode: { id: 'dataMode', type: 'dropdown', value: 'json' }, + data: { + id: 'data', + type: 'code', + value: `{"version": "${label}", "greeting": ""}`, + }, + status: { id: 'status', type: 'short-input', value: '200' }, + }, + }, + }, + edges: [{ id: 'start-respond', source: 'start', target: 'respond' }], + loops: {}, + parallels: {}, + variables: variables(greeting), + } +} + +const runInput = (mode: 'sync' | 'async') => ({ + workflowId, + requestId: generateId(), + input: {}, + mode, + requestHeaders: new Headers(), +}) + +async function logDeploymentVersionId(executionId: string) { + const [row] = await db + .select({ deploymentVersionId: workflowExecutionLogs.deploymentVersionId }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + return row?.deploymentVersionId +} + +/** Waits for a queued run, executed in-process by the database job backend, to settle. */ +async function settledRun(executionId: string) { + return vi.waitFor( + async () => { + const status = await getWorkflowExecutionStatus({ + workflowId, + executionId, + includeOutput: true, + selectedOutputs: [], + workspaceId, + viewerUserId: ownerId, + }) + if (status?.status !== 'completed' && status?.status !== 'failed') { + throw new Error(`Run ${executionId} has not settled: ${status?.status}`) + } + return status + }, + { timeout: 30_000, interval: 250 } + ) +} + +beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: ownerId, + name: 'Pinned execute fixture', + email: `${ownerId}@pinned-execute.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Pinned execute fixture', + ownerId, + billedAccountUserId: ownerId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId: ownerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + await db.insert(workflow).values({ + id: workflowId, + userId: ownerId, + workspaceId, + name: 'Pinned execute fixture', + isDeployed: true, + deployedAt: now, + variables: variables('draft'), + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + await db.insert(workflowDeploymentVersion).values([ + { + id: versionIds.one, + workflowId, + version: 1, + state: deployedState('one', 'deployed-one'), + isActive: false, + }, + { + id: versionIds.two, + workflowId, + version: 2, + state: deployedState('two', 'deployed-two'), + isActive: true, + }, + ]) +}) + +afterAll(async () => { + await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workflowId, workflowId)) + if (jobIds.length > 0) await db.delete(asyncJobs).where(inArray(asyncJobs.id, jobIds)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, ownerId)) + await db.$client.end() +}) + +describe('executing a deployed workflow against real Postgres', () => { + it('runs the pinned version, not the active one, and records it on the log', async () => { + const result = await executeWorkflowVersionOperation.execute({ + principal, + input: { ...runInput('sync'), version: 1 }, + }) + + expect(result).toMatchObject({ + ok: true, + status: 'completed', + output: { data: { version: 'one', greeting: 'deployed-one' } }, + }) + if (!result.ok || !('status' in result)) throw new Error('Expected a settled sync run') + expect(await logDeploymentVersionId(result.executionId)).toBe(versionIds.one) + }) + + it('answers not_found for a version the workflow never deployed, before any run starts', async () => { + await expect( + executeWorkflowVersionOperation.execute({ + principal, + input: { ...runInput('sync'), version: 99 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('carries the pin through the async queue and runs that version in the worker', async () => { + const result = await executeWorkflowVersionOperation.execute({ + principal, + input: { ...runInput('async'), version: 1 }, + }) + if (!result.ok || !('queued' in result)) throw new Error('Expected a queued run') + jobIds.push(result.jobId) + + const [job] = await db.select().from(asyncJobs).where(eq(asyncJobs.id, result.jobId)) + expect(job.payload).toMatchObject({ pinnedDeploymentVersionId: versionIds.one }) + + const run = await settledRun(result.executionId) + expect(run).toMatchObject({ + status: 'completed', + deploymentVersion: 1, + finalOutput: { data: { version: 'one', greeting: 'deployed-one' } }, + }) + }) +}) From b10ab69dfdd4617fb252381690b2c4d9d9a43bdf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 12:58:41 -0800 Subject: [PATCH 06/21] fix(execution): use deployed variables for async deployed runs An unpinned queued run seeded its execution snapshot with the workflow row's variables, which are the editor's draft, while the sync and stream paths use the variables of the deployed state they run. Load the active deployment's variables in the worker as the sync path does, so a queued run of the deployment no longer sees unsaved or undeployed variable edits. --- apps/sim/background/workflow-execution.ts | 23 ++++++++-------- .../execute-workflow-version.integration.ts | 27 +++++++++++++++++-- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index d148aec63ec..68ab7a8b089 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -47,7 +47,10 @@ import { isRecordedWorkflowFailure, resolveDeletedExecutionScope, } from '@/lib/workflows/executor/recorded-failure' -import { loadWorkflowDeploymentVersionState } from '@/lib/workflows/persistence/utils' +import { + loadDeployedWorkflowState, + loadWorkflowDeploymentVersionState, +} from '@/lib/workflows/persistence/utils' import { WORKFLOW_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' @@ -279,15 +282,13 @@ export async function executeWorkflowJob( const workflow = preprocessResult.workflowRecord! const pinnedDeploymentVersionId = payload.pinnedDeploymentVersionId - const pinnedVariables = pinnedDeploymentVersionId - ? ( - await loadWorkflowDeploymentVersionState( - workflowId, - pinnedDeploymentVersionId, - workspaceId - ) - ).variables - : undefined + const deployedState = pinnedDeploymentVersionId + ? await loadWorkflowDeploymentVersionState( + workflowId, + pinnedDeploymentVersionId, + workspaceId + ) + : await loadDeployedWorkflowState(workflowId, workspaceId) const metadata: ExecutionMetadata = { requestId, @@ -316,7 +317,7 @@ export async function executeWorkflowJob( metadata, workflow, payload.input, - pinnedVariables ?? workflow.variables ?? {}, + deployedState.variables ?? workflow.variables ?? {}, [] ) diff --git a/apps/sim/lib/workflows/application/execute-workflow-version.integration.ts b/apps/sim/lib/workflows/application/execute-workflow-version.integration.ts index 7c4db44e667..4064debafd5 100644 --- a/apps/sim/lib/workflows/application/execute-workflow-version.integration.ts +++ b/apps/sim/lib/workflows/application/execute-workflow-version.integration.ts @@ -1,6 +1,7 @@ /** * Deployed runs against real PostgreSQL through the execute use cases: a pinned run executes - * the version it named rather than the active one, on the sync and the async path. + * the version it named rather than the active one, on the sync and the async path, and an + * unpinned async run reads the deployed variables a sync run reads, not the editor's draft. */ import { db } from '@sim/db' import { @@ -17,7 +18,10 @@ import { createSessionPrincipal } from '@sim/testing/factories/principal.factory import { generateId } from '@sim/utils/id' import { eq, inArray } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { executeWorkflowVersionOperation } from '@/lib/workflows/application/execute-workflow' +import { + executeWorkflowOperation, + executeWorkflowVersionOperation, +} from '@/lib/workflows/application/execute-workflow' import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' const ownerId = generateId() @@ -213,4 +217,23 @@ describe('executing a deployed workflow against real Postgres', () => { finalOutput: { data: { version: 'one', greeting: 'deployed-one' } }, }) }) + + it('runs an unpinned async run with the active deployment variables, as a sync run does', async () => { + const sync = await executeWorkflowOperation.execute({ principal, input: runInput('sync') }) + expect(sync).toMatchObject({ + status: 'completed', + output: { data: { version: 'two', greeting: 'deployed-two' } }, + }) + + const queued = await executeWorkflowOperation.execute({ principal, input: runInput('async') }) + if (!queued.ok || !('queued' in queued)) throw new Error('Expected a queued run') + jobIds.push(queued.jobId) + + const run = await settledRun(queued.executionId) + expect(run).toMatchObject({ + status: 'completed', + deploymentVersion: 2, + finalOutput: { data: { version: 'two', greeting: 'deployed-two' } }, + }) + }) }) From 29119010a1c9cf61e3bdc8bf0b7050eaf637c407 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 13:03:25 -0800 Subject: [PATCH 07/21] refactor(execution): share the deployment version load and pause display gate The core loads a resumed run's admitted version and a pinned run's version through one branch. The scrubbed-display rule that persisting and mapping a pause point both apply is one helper, and mapping is the single gate the run detail relies on. The pin rides the queue payload as a plain optional field, and a whitespace-only input field name is no longer a field. --- apps/sim/executor/human-in-the-loop/utils.ts | 2 +- .../application/read-workflow-run.ts | 3 +- .../workflows/executor/enqueue-execution.ts | 2 +- .../lib/workflows/executor/execution-core.ts | 43 +++++++------------ .../workflows/executor/execution-status.ts | 5 +-- .../executor/human-in-the-loop-manager.ts | 31 +++++++------ 6 files changed, 37 insertions(+), 49 deletions(-) diff --git a/apps/sim/executor/human-in-the-loop/utils.ts b/apps/sim/executor/human-in-the-loop/utils.ts index 79ce35d3f5e..b8b53ae5e93 100644 --- a/apps/sim/executor/human-in-the-loop/utils.ts +++ b/apps/sim/executor/human-in-the-loop/utils.ts @@ -77,7 +77,7 @@ export function mapNodeMetadataToPauseScopes( } function toPauseInputField(field: unknown): PauseInputField | null { - if (!isRecordLike(field) || typeof field.name !== 'string' || !field.name) return null + if (!isRecordLike(field) || typeof field.name !== 'string' || !field.name.trim()) return null return { name: field.name, label: typeof field.label === 'string' ? field.label : field.name, diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index a0b3da41981..ddc975d324b 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -17,7 +17,6 @@ import { } from '@/lib/workflows/executor/execution-run-files' import { getProjectedWorkflowExecutionStatus, - type PausedRunPoint, readPausedRunPoints, } from '@/lib/workflows/executor/execution-status' @@ -156,7 +155,7 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ * Every pause point of a paused run, so a caller can resume any of them by * `contextId`, not only the earliest one `paused` summarizes. */ - const pausePoints: PausedRunPoint[] | null = status.paused + const pausePoints = status.paused ? ((await readPausedRunPoints({ workflowId: context.workflowId, executionId: context.runId, diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index 0190d195bda..fae9cb0079b 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -127,7 +127,7 @@ export async function enqueueWorkflowExecution( admissionCompleted: true, executionTimeoutMs, trustedInitialResolvedSecretTraceProvenance, - ...(pinnedDeploymentVersionId ? { pinnedDeploymentVersionId } : {}), + pinnedDeploymentVersionId, } let jobQueue: Awaited> diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index c5b7296286f..6ffa938f2c0 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -728,38 +728,25 @@ async function executeWorkflowCoreImpl( * on the environment load, so the two are awaited concurrently below. */ const loadWorkflowState = async () => { - if (resumeFromSnapshot && !useDraftState) { - if (!resumeDeploymentVersionId) { - throw new Error('Deployed resume requires its admitted deployment version') - } - const deployedData = await loadWorkflowDeploymentVersionState( - workflowId, - resumeDeploymentVersionId, - providedWorkspaceId - ) - logger.info('Using admitted historical deployment state (resumed execution)', { requestId }) - return { - blocks: deployedData.blocks, - edges: deployedData.edges, - loops: deployedData.loops, - parallels: deployedData.parallels, - deploymentVersionId: deployedData.deploymentVersionId, - } - } - - if (pinnedDeploymentVersionId) { - const pinnedData = await loadWorkflowDeploymentVersionState( + /** A deployed resume reloads the version its log admitted; a pinned run, its pin. */ + const versionId = + resumeFromSnapshot && !useDraftState ? resumeDeploymentVersionId : pinnedDeploymentVersionId + if (versionId) { + const versionData = await loadWorkflowDeploymentVersionState( workflowId, - pinnedDeploymentVersionId, + versionId, providedWorkspaceId ) - logger.info('Using pinned deployment version state', { requestId }) + logger.info('Using a selected deployment version state', { + requestId, + resumed: resumeFromSnapshot, + }) return { - blocks: pinnedData.blocks, - edges: pinnedData.edges, - loops: pinnedData.loops, - parallels: pinnedData.parallels, - deploymentVersionId: pinnedData.deploymentVersionId, + blocks: versionData.blocks, + edges: versionData.edges, + loops: versionData.loops, + parallels: versionData.parallels, + deploymentVersionId: versionData.deploymentVersionId, } } diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index d000aec2ef4..a6c1a7606b6 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -623,7 +623,6 @@ export async function readPausedRunPoints(input: { return points.map((point) => { const blockId = baseBlockId(point) - const scrubbed = point.displayScrubbed === true return { contextId: point.contextId, blockId, @@ -642,8 +641,8 @@ export async function readPausedRunPoints(input: { loop: point.loopScope ? { loopId: point.loopScope.loopId, iteration: point.loopScope.iteration } : null, - inputFields: scrubbed ? (point.inputFields ?? null) : null, - display: scrubbed ? (point.display ?? null) : null, + inputFields: point.inputFields ?? null, + display: point.display ?? null, queuePosition: point.queuePosition ?? null, } }) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index e8e57c30f61..d985b565004 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -266,6 +266,21 @@ function getSnapshotWorkspaceId(snapshotValue: unknown): string | undefined { return typeof metadata.workspaceId === 'string' ? metadata.workspaceId : undefined } +/** + * The pause display fields of a point captured with scrubbing. A point persisted before + * scrubbing existed stored its response unscrubbed, so it carries none of them. + */ +function scrubbedPauseDisplay( + point: Pick +): Pick { + if (point.displayScrubbed !== true) return {} + return { + displayScrubbed: true, + display: point.display ?? null, + inputFields: point.inputFields ?? null, + } +} + function isResumablePausedStatus(status: string): boolean { return RESUMABLE_PAUSED_STATUSES.includes(status as (typeof RESUMABLE_PAUSED_STATUSES)[number]) } @@ -605,13 +620,7 @@ export class PauseResumeManager { resumeLinks: point.resumeLinks, pauseKind: point.pauseKind, resumeAt: point.resumeAt, - ...(point.displayScrubbed - ? { - displayScrubbed: true, - display: point.display ?? null, - inputFields: point.inputFields ?? null, - } - : {}), + ...scrubbedPauseDisplay(point), } return acc }, {}) @@ -3840,13 +3849,7 @@ export class PauseResumeManager { resumeLinks, pauseKind: point.pauseKind ?? 'human', resumeAt: point.resumeAt, - ...(point.displayScrubbed === true - ? { - displayScrubbed: true, - display: point.display ?? null, - inputFields: point.inputFields ?? null, - } - : {}), + ...scrubbedPauseDisplay(point), queuePosition, latestResumeEntry: latestEntry ?? null, } From c589406be19e0aedc51a40c786ad65d9090145f7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 13:03:26 -0800 Subject: [PATCH 08/21] chore(api): document how to run the pinned execute e2e script Adds the test:workflow-pinned-execute:e2e package script and names the environment the script needs against a dev stack. --- apps/sim/package.json | 1 + apps/sim/scripts/test-workflow-pinned-execute-e2e.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index 915384bc341..a2e6c3b434b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -31,6 +31,7 @@ "test:landing-seo:e2e": "bun --no-env-file scripts/test-landing-seo-e2e.ts", "test:workflow-version-compare:e2e": "bun --no-env-file scripts/test-workflow-version-compare-e2e.ts", "test:workflow-stop-after:e2e": "bun --no-env-file scripts/test-workflow-stop-after-e2e.ts", + "test:workflow-pinned-execute:e2e": "bun --no-env-file scripts/test-workflow-pinned-execute-e2e.ts", "test:desktop-inbox:e2e": "bun --no-env-file scripts/test-desktop-inbox-e2e.ts", "test:cli-run-latency:e2e": "bun --no-env-file scripts/test-cli-run-latency-e2e.ts", "test:watch": "vitest", diff --git a/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts index f636928bb33..7a7a7783574 100644 --- a/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts +++ b/apps/sim/scripts/test-workflow-pinned-execute-e2e.ts @@ -25,7 +25,11 @@ import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' * differ only in which block runs, so a run's block outputs and its recorded * `deployment_version_id` show which graph executed. Before the executor honored * the pin, a pinned run loaded version 1 for its variables and still executed - * version 2 — the first check fails on that code. + * version 2 — the pinned checks fail on that code. + * + * Run against a dev stack with `bun run test:workflow-pinned-execute:e2e`, setting + * `PINNED_EXECUTE_E2E_BASE_URL` (the loopback app origin), `PINNED_EXECUTE_E2E_DATABASE_URL` + * (the disposable database that app uses), and `PINNED_EXECUTE_E2E_REPORT_PATH`. */ const logger = createLogger('WorkflowPinnedExecuteE2E') const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 From 98f6bd8512d26cd66ccd3f7b7776e0a5a451cd07 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 13:22:38 -0800 Subject: [PATCH 09/21] fix(execution): use deployed variables for scheduled and webhook runs Scheduled and webhook runs executed their deployed version's blocks with the workflow row's saved variables, the editor's draft. They now use the variables the version was deployed with, as queued API runs do. A version deployed before deployments snapshotted variables has no variables in its snapshot, and the loader defaults them to {}. Those runs have always used the saved variables, so the loader now reports whether the snapshot recorded variables and resolveDeployedRunVariables keeps the saved ones only for such a version, on the queued API, schedule, and webhook paths. The sync path is unchanged. Proven against real Postgres by running a schedule and a generic webhook delivery in-process; both read the saved variables before this change, and the pre-snapshot case reads {} without the fallback. --- .../deployed-run-variables.integration.ts | 270 ++++++++++++++++++ apps/sim/background/schedule-execution.ts | 7 +- apps/sim/background/webhook-execution.ts | 4 +- apps/sim/background/workflow-execution.ts | 3 +- apps/sim/lib/workflows/persistence/utils.ts | 20 ++ .../mocks/workflows-persistence-utils.mock.ts | 10 + 6 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 apps/sim/background/deployed-run-variables.integration.ts diff --git a/apps/sim/background/deployed-run-variables.integration.ts b/apps/sim/background/deployed-run-variables.integration.ts new file mode 100644 index 00000000000..b5daeafa068 --- /dev/null +++ b/apps/sim/background/deployed-run-variables.integration.ts @@ -0,0 +1,270 @@ +/** + * Scheduled and webhook runs against real PostgreSQL: each executes its deployed version with + * the variables that version was deployed with, not the editor's saved ones, except for a + * version deployed before deployments snapshotted variables, which keeps the saved ones. + */ +import { serializePrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + permissions, + user, + webhook, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, + workflowSchedule, + workspace, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { resolveBillingAttribution } from '@/lib/billing/core/attribution' +import { createWebhookExecutionPrincipal } from '@/lib/webhooks/execution-principal' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { executeScheduleJob } from '@/background/schedule-execution' +import { executeWebhookJob } from '@/background/webhook-execution' + +const ownerId = generateId() +const workspaceId = generateId() +const workflowIds: string[] = [] + +function variables(workflowId: string, greeting: string) { + return { + greeting: { id: 'greeting', workflowId, name: 'greeting', type: 'plain', value: greeting }, + } +} + +const SCHEDULE_TRIGGER = { + id: 'trigger', + type: 'schedule', + name: 'Schedule', + enabled: true, + position: { x: 0, y: 0 }, + outputs: {}, + subBlocks: { + scheduleType: { id: 'scheduleType', type: 'dropdown', value: 'custom' }, + cronExpression: { id: 'cronExpression', type: 'short-input', value: '0 0 * * *' }, + }, +} + +const WEBHOOK_TRIGGER = { + id: 'trigger', + type: 'generic_webhook', + name: 'Webhook', + enabled: true, + position: { x: 0, y: 0 }, + outputs: {}, + subBlocks: {}, + triggerMode: true, +} + +/** Trigger into a Response echoing the `greeting` variable; `variables` omitted for a legacy snapshot. */ +function deployedState( + trigger: typeof SCHEDULE_TRIGGER | typeof WEBHOOK_TRIGGER, + snapshotVariables: Record | undefined +) { + return { + blocks: { + trigger, + respond: { + id: 'respond', + type: 'response', + name: 'Respond', + enabled: true, + position: { x: 300, y: 0 }, + outputs: {}, + subBlocks: { + dataMode: { id: 'dataMode', type: 'dropdown', value: 'json' }, + data: { id: 'data', type: 'code', value: '{"greeting": ""}' }, + status: { id: 'status', type: 'short-input', value: '200' }, + }, + }, + }, + edges: [{ id: 'trigger-respond', source: 'trigger', target: 'respond' }], + loops: {}, + parallels: {}, + ...(snapshotVariables ? { variables: snapshotVariables } : {}), + } +} + +/** A workflow deployed once, whose saved variables differ from the deployed ones. */ +async function deployWorkflow( + trigger: typeof SCHEDULE_TRIGGER | typeof WEBHOOK_TRIGGER, + { snapshotsVariables }: { snapshotsVariables: boolean } +) { + const workflowId = generateId() + const versionId = generateId() + workflowIds.push(workflowId) + const now = new Date() + await db.insert(workflow).values({ + id: workflowId, + userId: ownerId, + workspaceId, + name: `Deployed variables fixture ${workflowId}`, + isDeployed: true, + deployedAt: now, + variables: variables(workflowId, 'saved'), + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + await db.insert(workflowDeploymentVersion).values({ + id: versionId, + workflowId, + version: 1, + state: deployedState( + trigger, + snapshotsVariables ? variables(workflowId, 'deployed') : undefined + ), + isActive: true, + }) + return { workflowId, versionId } +} + +async function settledOutput(workflowId: string, executionId: string) { + return vi.waitFor( + async () => { + const status = await getWorkflowExecutionStatus({ + workflowId, + executionId, + includeOutput: true, + selectedOutputs: [], + workspaceId, + viewerUserId: ownerId, + }) + if (status?.status !== 'completed') { + throw new Error(`Run ${executionId} has not completed: ${status?.status} ${status?.error}`) + } + return status.finalOutput + }, + { timeout: 30_000, interval: 250 } + ) +} + +async function runSchedule(workflowId: string, versionId: string) { + const scheduleId = generateId() + const executionId = generateId() + const now = new Date() + await db.insert(workflowSchedule).values({ + id: scheduleId, + workflowId, + deploymentVersionId: versionId, + blockId: 'trigger', + cronExpression: '0 0 * * *', + triggerType: 'schedule', + lastQueuedAt: now, + createdAt: now, + updatedAt: now, + }) + await executeScheduleJob({ + scheduleId, + workflowId, + workspaceId, + billingAttribution: await resolveBillingAttribution({ actorUserId: ownerId, workspaceId }), + executionId, + blockId: 'trigger', + deploymentVersionId: versionId, + cronExpression: '0 0 * * *', + now: now.toISOString(), + }) + return settledOutput(workflowId, executionId) +} + +async function runWebhook(workflowId: string, versionId: string) { + const webhookId = generateId() + const executionId = generateId() + await db.insert(webhook).values({ + id: webhookId, + workflowId, + deploymentVersionId: versionId, + blockId: 'trigger', + path: `deployed-variables-${webhookId}`, + provider: 'generic', + providerConfig: {}, + }) + await executeWebhookJob({ + webhookId, + workflowId, + principal: serializePrincipal( + createWebhookExecutionPrincipal({ webhookId, workflowId, workspaceId, provider: 'generic' }) + ), + userId: ownerId, + billingAttribution: await resolveBillingAttribution({ actorUserId: ownerId, workspaceId }), + executionId, + provider: 'generic', + body: { event: 'ping' }, + headers: {}, + method: 'POST', + path: `deployed-variables-${webhookId}`, + blockId: 'trigger', + deploymentVersionId: versionId, + workspaceId, + }) + return settledOutput(workflowId, executionId) +} + +beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: ownerId, + name: 'Deployed variables fixture', + email: `${ownerId}@deployed-variables.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Deployed variables fixture', + ownerId, + billedAccountUserId: ownerId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId: ownerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) +}) + +afterAll(async () => { + if (workflowIds.length > 0) { + await db + .delete(workflowExecutionLogs) + .where(inArray(workflowExecutionLogs.workflowId, workflowIds)) + } + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, ownerId)) + await db.$client.end() +}) + +describe('variables a queued deployed run executes with', () => { + it('runs a schedule with its deployed variables, not the saved ones', async () => { + const { workflowId, versionId } = await deployWorkflow(SCHEDULE_TRIGGER, { + snapshotsVariables: true, + }) + await expect(runSchedule(workflowId, versionId)).resolves.toMatchObject({ + data: { greeting: 'deployed' }, + }) + }) + + it('runs a webhook delivery with its deployed variables, not the saved ones', async () => { + const { workflowId, versionId } = await deployWorkflow(WEBHOOK_TRIGGER, { + snapshotsVariables: true, + }) + await expect(runWebhook(workflowId, versionId)).resolves.toMatchObject({ + data: { greeting: 'deployed' }, + }) + }) + + it('keeps the saved variables for a version deployed before variables were snapshotted', async () => { + const { workflowId, versionId } = await deployWorkflow(SCHEDULE_TRIGGER, { + snapshotsVariables: false, + }) + await expect(runSchedule(workflowId, versionId)).resolves.toMatchObject({ + data: { greeting: 'saved' }, + }) + }) +}) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 533d5eb0063..eeabc810762 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -37,7 +37,10 @@ import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base6 import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { isRecordedWorkflowFailure } from '@/lib/workflows/executor/recorded-failure' -import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' +import { + loadDeployedWorkflowState, + resolveDeployedRunVariables, +} from '@/lib/workflows/persistence/utils' import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' import { @@ -622,7 +625,7 @@ async function runWorkflowExecution({ metadata, workflowRecord, input, - workflowRecord.variables || {}, + resolveDeployedRunVariables(deployedData, workflowRecord.variables), [] ) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 660840b0cf2..bd4387132b7 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -84,6 +84,7 @@ import { import { loadDeployedWorkflowState, loadWorkflowDeploymentVersionState, + resolveDeployedRunVariables, } from '@/lib/workflows/persistence/utils' import { WEBHOOK_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits' import { getBlock } from '@/blocks' @@ -867,8 +868,6 @@ async function executeWebhookJobInternal( throw new Error(`Workflow ${payload.workflowId} has no associated workspace`) } - const workflowVariables = (workflowRecord.variables as Record) || {} - let deploymentVersionId: string | undefined /** * Flipped immediately before `executeWorkflowCore` is invoked. While false, @@ -917,6 +916,7 @@ async function executeWebhookJobInternal( } const { blocks, edges, loops, parallels } = workflowData + const workflowVariables = resolveDeployedRunVariables(workflowData, workflowRecord.variables) deploymentVersionId = 'deploymentVersionId' in workflowData ? (workflowData.deploymentVersionId as string) diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index 68ab7a8b089..d6b6bf1d44c 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -50,6 +50,7 @@ import { import { loadDeployedWorkflowState, loadWorkflowDeploymentVersionState, + resolveDeployedRunVariables, } from '@/lib/workflows/persistence/utils' import { WORKFLOW_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits' import { ExecutionSnapshot } from '@/executor/execution/snapshot' @@ -317,7 +318,7 @@ export async function executeWorkflowJob( metadata, workflow, payload.input, - deployedState.variables ?? workflow.variables ?? {}, + resolveDeployedRunVariables(deployedState, workflow.variables), [] ) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index c198eb282de..7aa642764d9 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -10,6 +10,7 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { toRecord } from '@sim/utils/object' import { loadWorkflowFromNormalizedTablesRaw, persistMigratedBlocks, @@ -97,6 +98,24 @@ export interface WorkflowDeploymentVersionResponse { export interface DeployedWorkflowData extends NormalizedWorkflowData { deploymentVersionId: string variables?: Record + /** + * Whether the version's snapshot recorded its variables. Versions deployed before + * deployments snapshotted variables did not, and `variables` defaults to `{}` for them. + */ + variablesSnapshotted?: boolean +} + +/** + * The variables a queued deployed run (async API, schedule, webhook) executes with: the + * deployed version's own, or the workflow's saved variables for a version deployed before + * deployments snapshotted them, which is what those runs have always used for such a version. + */ +export function resolveDeployedRunVariables( + deployedState: DeployedWorkflowData, + savedVariables: unknown +): Record { + if (deployedState.variablesSnapshotted) return deployedState.variables ?? {} + return toRecord(savedVariables) } /** @@ -268,6 +287,7 @@ export async function materializeDeploymentState( loops: state.loops || {}, parallels: state.parallels || {}, variables: state.variables || {}, + variablesSnapshotted: state.variables != null, isFromNormalizedTables: false, deploymentVersionId: version.id, } diff --git a/packages/testing/src/mocks/workflows-persistence-utils.mock.ts b/packages/testing/src/mocks/workflows-persistence-utils.mock.ts index a131c1dee42..6b2e0ba74d8 100644 --- a/packages/testing/src/mocks/workflows-persistence-utils.mock.ts +++ b/packages/testing/src/mocks/workflows-persistence-utils.mock.ts @@ -95,6 +95,16 @@ export const workflowsPersistenceUtilsMock = { findPreviousDeploymentVersion: workflowsPersistenceUtilsMockFns.mockFindPreviousDeploymentVersion, getWorkflowDeploymentVersion: workflowsPersistenceUtilsMockFns.mockGetWorkflowDeploymentVersion, NoActiveDeploymentError: MockNoActiveDeploymentError, + /** Real behavior: a snapshot's own variables, or the saved ones for a pre-snapshot version. */ + resolveDeployedRunVariables: ( + deployedState: { variables?: Record; variablesSnapshotted?: boolean }, + savedVariables: unknown + ): Record => + deployedState.variablesSnapshotted + ? (deployedState.variables ?? {}) + : savedVariables && typeof savedVariables === 'object' && !Array.isArray(savedVariables) + ? (savedVariables as Record) + : {}, /** Real value: the sub-block ids that hold a credential reference. */ CREDENTIAL_SUBBLOCK_IDS: new Set([ 'credential', From 7a96b17b8e9c5a104c7f2747c5271991aba6d70f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 13:22:43 -0800 Subject: [PATCH 10/21] fix(execution): run a queued API run on the version it loaded The worker loaded the active deployment for its variables and the core then loaded the active deployment again for its blocks, so a deploy landing between the two could pair one version's variables with another's graph. The worker now pins the core to the version it loaded, and retries that read on a transient database error as the core's own load does. Schedules and webhooks already hand the core the blocks of the version they loaded, so they need no change. --- apps/sim/background/workflow-execution.ts | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index d6b6bf1d44c..809b9493ec2 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -32,6 +32,7 @@ import { RESERVATION_TTL_BUFFER_MS, } from '@/lib/core/execution-limits' import type { RequestAttribution } from '@/lib/core/utils/request-attribution' +import { withDatabaseReadRetry } from '@/lib/db/read-retry' import { isWorkflowStateRejection, type PreprocessExecutionError, @@ -282,14 +283,23 @@ export async function executeWorkflowJob( logger.info(`Preprocessing passed. Using actor: ${actorUserId}`, { requestId }) const workflow = preprocessResult.workflowRecord! + /** + * The version this run executes, loaded once: the core runs this same version's + * blocks, so a deploy landing mid-setup cannot pair one version's variables with + * another's graph. + */ const pinnedDeploymentVersionId = payload.pinnedDeploymentVersionId - const deployedState = pinnedDeploymentVersionId - ? await loadWorkflowDeploymentVersionState( - workflowId, - pinnedDeploymentVersionId, - workspaceId - ) - : await loadDeployedWorkflowState(workflowId, workspaceId) + const deployedState = await withDatabaseReadRetry( + () => + pinnedDeploymentVersionId + ? loadWorkflowDeploymentVersionState( + workflowId, + pinnedDeploymentVersionId, + workspaceId + ) + : loadDeployedWorkflowState(workflowId, workspaceId), + { label: 'loadDeployedRunState' } + ) const metadata: ExecutionMetadata = { requestId, @@ -304,7 +314,7 @@ export async function executeWorkflowJob( triggerType: payload.triggerType || 'api', triggerBlockId: payload.triggerBlockId, useDraftState: false, - pinnedDeploymentVersionId, + pinnedDeploymentVersionId: deployedState.deploymentVersionId, startTime: new Date().toISOString(), isClientSession: false, enforceCredentialAccess: payload.enforceCredentialAccess ?? false, From ceba3309d1e33f9e97787be55d66b13e2fd9b4de Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 13:22:51 -0800 Subject: [PATCH 11/21] fix(api): withhold pause display on the run output's terms A paused run's pause points carried each point's reviewer display whatever the caller asked for or the viewer's permission group withheld, though the display is rendered from upstream block output. The run detail now returns display only with includeOutput and when logs.trace_spans is not denied, as it does finalOutput, blockOutputs, and files; input fields are block configuration and stay. The display and the input fields are captured as separate projections, so content too large to prove free of secrets withholds only itself. A point recorded without a registration time lists with registeredAt null instead of failing the response. A real human-in-the-loop pause run in-process through the engine, with a workspace secret in its content, proves the stored point and the run detail carry the redacted reference, and that both display gates hold. --- apps/docs/openapi-v2-workflows.json | 17 +- apps/sim/executor/human-in-the-loop/utils.ts | 29 +- apps/sim/lib/api/contracts/v2/workflows.ts | 11 +- .../read-workflow-run-pause.integration.ts | 286 ++++++++++++++++++ .../application/read-workflow-run.ts | 18 +- .../workflows/executor/execution-status.ts | 5 +- .../executor/pause-persistence.integration.ts | 22 ++ packages/sim-cli/src/generated/v2-api.ts | 2 +- 8 files changed, 361 insertions(+), 29 deletions(-) create mode 100644 apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 4850b9b166f..4a10f9d7acb 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -14819,8 +14819,15 @@ "description": "Resume state of this point: waiting, queued behind another resume, resuming, resumed, or a failed resume attempt." }, "registeredAt": { - "type": "string", - "description": "ISO 8601 timestamp when the run paused at this point.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the run paused at this point, or null for a point recorded without one.", "format": "date-time" }, "resumeAt": { @@ -14910,18 +14917,18 @@ "type": "null" } ], - "description": "Fields a human pause asks for, without default values. Null for a time pause, a pause with no declared form, or one recorded before pause details were captured." + "description": "Fields a human pause asks for, without default values. Null for a time pause, a pause with no declared form, fields over 64 KiB or not provably free of secrets, or one recorded before pause details were captured." }, "display": { "anyOf": [ { - "description": "Content the pause presents to its reviewer." + "description": "Reviewer content value." }, { "type": "null" } ], - "description": "Content the pause presents to its reviewer, with the run's resolved secrets redacted. Null for a time pause, content over 64 KiB, or a pause recorded before pause details were captured." + "description": "Content the pause presents to its reviewer, with the run's resolved secrets redacted. Null unless `includeOutput=true`, when hidden by organization permission groups, for a time pause, for content over 64 KiB or not provably free of secrets, or for a pause recorded before pause details were captured." }, "queuePosition": { "anyOf": [ diff --git a/apps/sim/executor/human-in-the-loop/utils.ts b/apps/sim/executor/human-in-the-loop/utils.ts index b8b53ae5e93..2c000374b77 100644 --- a/apps/sim/executor/human-in-the-loop/utils.ts +++ b/apps/sim/executor/human-in-the-loop/utils.ts @@ -11,7 +11,7 @@ import { projectResolvedSecretModelJsonContent } from '@/executor/utils/resolved import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { buildBranchNodeId } from '@/executor/utils/subflow-node-id-codec' -/** Per-point ceiling on captured display content; larger content is withheld, not truncated. */ +/** Per-part ceiling on captured display content and fields; larger parts are withheld, not truncated. */ const MAX_PAUSE_DISPLAY_BYTES = 64 * 1024 /** Response keys that drive resume and rendering rather than describe what the reviewer sees. */ @@ -96,8 +96,9 @@ function toPauseInputField(field: unknown): PauseInputField | null { * `{{API_KEY}}` in its configuration is stored resolved. This projects the parts a caller * reading the run may see — the content without resume links, control keys, or API-mode status * and headers, and the input fields without their default values — through the run's committed - * secret matches. An unprovable projection (no registry, an incomplete one, or content over - * {@link MAX_PAUSE_DISPLAY_BYTES}) yields neither. Time pauses carry no reviewer content. + * secret matches. Content and fields are proven separately: a part whose projection is + * unprovable (no registry, an incomplete one, or over {@link MAX_PAUSE_DISPLAY_BYTES}) is null + * while the other may survive. Time pauses carry no reviewer content. */ export function capturePauseDisplay( pause: Pick, @@ -117,17 +118,19 @@ export function capturePauseDisplay( ? data.inputFormat.flatMap((field) => toPauseInputField(field) ?? []) : null - const projection = projectResolvedSecretModelJsonContent( - { content, inputFields }, - registry, - MAX_PAUSE_DISPLAY_BYTES - ) - if (!projection.safe || !isRecordLike(projection.value)) return withheld + /** Each part is projected on its own, so content too large to prove withholds only itself. */ + const project = (value: unknown): unknown => { + const projection = projectResolvedSecretModelJsonContent( + value, + registry, + MAX_PAUSE_DISPLAY_BYTES + ) + return projection.safe ? projection.value : null + } + const projectedFields = inputFields ? project(inputFields) : null return { displayScrubbed: true, - display: projection.value.content ?? null, - inputFields: Array.isArray(projection.value.inputFields) - ? (projection.value.inputFields as PauseInputField[]) - : null, + display: project(content) ?? null, + inputFields: Array.isArray(projectedFields) ? (projectedFields as PauseInputField[]) : null, } } diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 5d6c8e72b02..f552a785db3 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1942,7 +1942,10 @@ const v2WorkflowRunPausePointSchema = z ), registeredAt: z .string() - .describe('ISO 8601 timestamp when the run paused at this point.') + .nullable() + .describe( + 'ISO 8601 timestamp when the run paused at this point, or null for a point recorded without one.' + ) .meta({ format: 'date-time' }), resumeAt: z .string() @@ -1972,14 +1975,14 @@ const v2WorkflowRunPausePointSchema = z .array(v2PauseInputFieldSchema) .nullable() .describe( - 'Fields a human pause asks for, without default values. Null for a time pause, a pause with no declared form, or one recorded before pause details were captured.' + 'Fields a human pause asks for, without default values. Null for a time pause, a pause with no declared form, fields over 64 KiB or not provably free of secrets, or one recorded before pause details were captured.' ), display: z .unknown() - .describe('Content the pause presents to its reviewer.') + .describe('Reviewer content value.') .nullable() .describe( - "Content the pause presents to its reviewer, with the run's resolved secrets redacted. Null for a time pause, content over 64 KiB, or a pause recorded before pause details were captured." + "Content the pause presents to its reviewer, with the run's resolved secrets redacted. Null unless `includeOutput=true`, when hidden by organization permission groups, for a time pause, for content over 64 KiB or not provably free of secrets, or for a pause recorded before pause details were captured." ), queuePosition: z .number() diff --git a/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts b/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts new file mode 100644 index 00000000000..8b5a29b7a06 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-run-pause.integration.ts @@ -0,0 +1,286 @@ +/** + * A deployed run that pauses at a human-in-the-loop block, executed in-process against real + * PostgreSQL: the pause's reviewer content is stored and read back with the run's resolved + * secret redacted, and the run detail withholds that content on the same terms as the run's + * output, while the pause's input fields stay readable. + */ +import { db } from '@sim/db' +import { + member, + organization, + pausedExecutions, + permissionGroup, + permissionGroupMember, + permissionGroupWorkspace, + permissions, + user, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, + workspace, + workspaceEnvironment, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { encryptSecret } from '@/lib/core/security/encryption' +import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' +import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' +import type { PausePoint } from '@/executor/types' + +const organizationId = generateId() +const ownerId = generateId() +const restrictedId = generateId() +const workspaceId = generateId() +const workflowId = generateId() +const owner = createSessionPrincipal({ userId: ownerId, sessionId: generateId() }) +const restricted = createSessionPrincipal({ userId: restrictedId, sessionId: generateId() }) +const SECRET = `resolved-secret-${generateId()}` + +const deployedState = { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + enabled: true, + position: { x: 0, y: 0 }, + outputs: {}, + subBlocks: {}, + }, + approval: { + id: 'approval', + type: 'human_in_the_loop', + name: 'Approval', + enabled: true, + position: { x: 300, y: 0 }, + outputs: {}, + subBlocks: { + builderData: { + id: 'builderData', + type: 'response-format', + value: [ + { id: 'summary', name: 'summary', type: 'string', value: 'Token is {{API_KEY}}' }, + ], + }, + inputFormat: { + id: 'inputFormat', + type: 'input-format', + value: [{ id: 'approved', name: 'approved', type: 'boolean', required: true }], + }, + }, + }, + }, + edges: [{ id: 'start-approval', source: 'start', target: 'approval' }], + loops: {}, + parallels: {}, + variables: {}, +} + +async function pausedRun(): Promise { + const result = await executeWorkflowOperation.execute({ + principal: owner, + input: { + workflowId, + requestId: generateId(), + input: {}, + mode: 'sync', + requestHeaders: new Headers(), + }, + }) + expect(result).toMatchObject({ ok: true, status: 'paused' }) + if (!result.ok || !('status' in result)) throw new Error('Expected a settled sync run') + const { executionId } = result + await vi.waitFor( + async () => { + const [row] = await db + .select({ id: pausedExecutions.id }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + if (!row) throw new Error('Pause not yet published') + }, + { timeout: 30_000, interval: 250 } + ) + return executionId +} + +function readRun( + principal: typeof owner, + runId: string, + { includeOutput }: { includeOutput: boolean } +) { + return readWorkflowRun.execute({ + principal, + input: { workflowId, runId, includeOutput, selectedOutputs: [] }, + }) +} + +const REDACTED_DISPLAY = { + responseStructure: [{ name: 'summary', type: 'string', value: 'Token is {{API_KEY}}' }], +} +const INPUT_FIELDS = [{ name: 'approved', label: 'approved', type: 'boolean', required: true }] + +beforeAll(async () => { + const now = new Date() + await db.insert(user).values( + [ownerId, restrictedId].map((id) => ({ + id, + name: 'Paused run fixture', + email: `${id}@paused-run.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Paused run fixture', + slug: organizationId, + createdAt: now, + updatedAt: now, + }) + await db.insert(member).values( + [ownerId, restrictedId].map((userId) => ({ + id: generateId(), + userId, + organizationId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: now, + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Paused run fixture', + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + }) + await db.insert(permissions).values([ + { + id: generateId(), + userId: ownerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }, + { + id: generateId(), + userId: restrictedId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'read', + }, + ]) + const restrictedGroupId = generateId() + await db.insert(permissionGroup).values([ + { + id: generateId(), + organizationId, + name: 'Everyone', + createdBy: ownerId, + isDefault: true, + config: {}, + }, + { + id: restrictedGroupId, + organizationId, + name: 'No execution data', + createdBy: ownerId, + config: { hideTraceSpans: true }, + }, + ]) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: restrictedGroupId, + workspaceId, + organizationId, + }) + await db.insert(permissionGroupMember).values({ + id: generateId(), + permissionGroupId: restrictedGroupId, + organizationId, + userId: restrictedId, + }) + await db.insert(workspaceEnvironment).values({ + id: generateId(), + workspaceId, + variables: { API_KEY: (await encryptSecret(SECRET)).encrypted }, + }) + await db.insert(workflow).values({ + id: workflowId, + userId: ownerId, + workspaceId, + name: 'Paused run fixture', + isDeployed: true, + deployedAt: now, + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + await db.insert(workflowDeploymentVersion).values({ + id: generateId(), + workflowId, + version: 1, + state: deployedState, + isActive: true, + }) +}) + +afterAll(async () => { + await db.delete(pausedExecutions).where(eq(pausedExecutions.workflowId, workflowId)) + await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workflowId, workflowId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [ownerId, restrictedId])) + await db.$client.end() +}) + +describe('a deployed run paused at a human-in-the-loop block', () => { + it('stores and returns its reviewer content with the resolved secret redacted', async () => { + const runId = await pausedRun() + + const [row] = await db + .select({ pausePoints: pausedExecutions.pausePoints }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, runId)) + const [stored] = Object.values(row.pausePoints as Record) + expect(stored).toMatchObject({ + displayScrubbed: true, + display: REDACTED_DISPLAY, + inputFields: INPUT_FIELDS, + }) + + const run = await readRun(owner, runId, { includeOutput: true }) + expect(run.pausePoints).toEqual([ + expect.objectContaining({ + blockId: 'approval', + blockName: 'Approval', + kind: 'human', + display: REDACTED_DISPLAY, + inputFields: INPUT_FIELDS, + }), + ]) + expect(JSON.stringify(run)).not.toContain(SECRET) + }) + + it('withholds reviewer content unless output was asked for, keeping the input fields', async () => { + const runId = await pausedRun() + + const run = await readRun(owner, runId, { includeOutput: false }) + expect(run.pausePoints).toEqual([ + expect.objectContaining({ display: null, inputFields: INPUT_FIELDS }), + ]) + }) + + it('withholds reviewer content from a viewer whose group hides execution data', async () => { + const runId = await pausedRun() + + const run = await readRun(restricted, runId, { includeOutput: true }) + expect(run.pausePoints).toEqual([ + expect.objectContaining({ display: null, inputFields: INPUT_FIELDS }), + ]) + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index ddc975d324b..6f14329f6a6 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -154,12 +154,22 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ /** * Every pause point of a paused run, so a caller can resume any of them by * `contextId`, not only the earliest one `paused` summarizes. + * + * A point's `display` is what the block rendered from upstream outputs, so it + * follows `finalOutput`'s gating: withheld unless output was asked for and the + * viewer's group shows execution data. `inputFields` are the block's own + * configuration and stay. + * + * permission-group-enforced: logs.trace_spans */ + const showsDisplay = input.includeOutput && !projection.hideTraceSpans const pausePoints = status.paused - ? ((await readPausedRunPoints({ - workflowId: context.workflowId, - executionId: context.runId, - })) ?? []) + ? ( + (await readPausedRunPoints({ + workflowId: context.workflowId, + executionId: context.runId, + })) ?? [] + ).map((point) => (showsDisplay ? point : { ...point, display: null })) : null return { ...status, files, pausePoints } diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index a6c1a7606b6..a1589d9190b 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -518,7 +518,8 @@ export interface PausedRunPoint { blockName: string | null kind: 'human' | 'time' status: ResumeStatus - registeredAt: string + /** Null for a point recorded without a registration time. */ + registeredAt: string | null resumeAt: string | null parallel: { parallelId: string; branchIndex: number; branchTotal: number | null } | null loop: { loopId: string; iteration: number } | null @@ -629,7 +630,7 @@ export async function readPausedRunPoints(input: { blockName: names.get(blockId) ?? null, kind: point.pauseKind === 'time' ? 'time' : 'human', status: point.resumeStatus, - registeredAt: point.registeredAt, + registeredAt: point.registeredAt ?? null, resumeAt: point.resumeAt ?? null, parallel: point.parallelScope ? { diff --git a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts index 7b421a60867..a73618a56ca 100644 --- a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts +++ b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts @@ -384,6 +384,28 @@ describe('readPausedRunPoints', () => { expect(JSON.stringify(point)).not.toContain(SECRET) }) + it('keeps the input fields when the reviewer content is too large to capture', async () => { + const point = humanBranchPoint(0, registryWithResolvedSecret()) + point.response.data.responseStructure[0].value = 'x'.repeat(70 * 1024) + const executionId = await publishPausedRun([ + { ...point, ...capturePauseDisplay(point, registryWithResolvedSecret()) }, + ]) + + const [read] = (await readPausedRunPoints({ workflowId: ids.workflow, executionId })) ?? [] + expect(read).toMatchObject({ + display: null, + inputFields: [{ name: 'approved', label: 'approved', type: 'boolean', required: true }], + }) + }) + + it('lists a point recorded without a registration time instead of failing the run', async () => { + const { registeredAt, ...unregistered } = humanBranchPoint(0, registryWithResolvedSecret()) + const executionId = await publishPausedRun([unregistered as PausePoint]) + + const [read] = (await readPausedRunPoints({ workflowId: ids.workflow, executionId })) ?? [] + expect(read).toMatchObject({ contextId: BRANCH_IDS[0], registeredAt: null }) + }) + it('reports a time pause with its resume time and no input fields', async () => { const resumeAt = new Date(Date.now() + 60_000).toISOString() const executionId = await publishPausedRun([ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 7153d859606..c2fe76ec99b 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -7872,7 +7872,7 @@ type GetWorkflowRunResponseRef0 = { blockName: string | null kind: 'human' | 'time' status: 'paused' | 'queued' | 'resuming' | 'resumed' | 'failed' - registeredAt: string + registeredAt: string | null resumeAt: string | null parallel: { parallelId: string From bccb528df4e6a049b44730920aa3163d771985e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 13:22:56 -0800 Subject: [PATCH 12/21] docs(api): document running a deployment version and paused run points Covers run.version on v2 execute (write access, keyed callers, 404 for an unknown version, version on the run status) and paused.points on the run detail, including when display is withheld. --- apps/docs/content/docs/workflows/deployment/api.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/docs/content/docs/workflows/deployment/api.mdx b/apps/docs/content/docs/workflows/deployment/api.mdx index 412b1e76c63..3d03519a335 100644 --- a/apps/docs/content/docs/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/workflows/deployment/api.mdx @@ -14,6 +14,17 @@ You need workspace **Admin** access and the applicable deployment permission. Te Ordinary API calls use the active snapshot. Editing the draft does not change that version. Deploy an update before expecting a normal API call to use your changes. +### Run an earlier version [#run-a-version] + +To run a specific deployment version instead of the active one, name its number in `run.version`. The workflow must still be deployed, and the caller needs workspace **Write** access with an API key or OAuth access token; anonymous public-API callers cannot pin a version. An unknown version returns `404`. The run's status reports the version it ran in `version`. + +```bash +curl -X POST 'https://www.sim.ai/api/v2/workflows/{workflow-id}/execute' \ + -H "X-API-Key: $SIM_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"input":{"message":"Summarize this article"},"run":{"source":"deployment","version":3}}' +``` + ## Authentication Send a valid Sim API key in `X-API-Key`, or use a supported OAuth access token. Deployed workflow execution requires access to the workflow's workspace; OAuth callers also need the operation's `api:write` scope. See [API authentication](/api-reference/authentication). @@ -249,6 +260,8 @@ A completed response includes the result when requested: Stop polling on `completed`, `failed`, or `cancelled`. A paused Human run requires submitted input; a time pause can resume automatically. See [Human](/workflows/blocks/human-in-the-loop) and [Wait](/workflows/blocks/wait). Resuming an originally streaming run through the v2 resume endpoint returns an async receipt, not an SSE continuation. +A paused run lists every point it waits at in `paused.points`: the block, whether it is a `human` or `time` pause, its resume state, any parallel branch or loop iteration, its place in the resume queue, and the `inputFields` a Human pause asks for. With `includeOutput=true`, each point also carries the reviewer `display` content with resolved secrets redacted; organization permission groups that hide execution data withhold it. Resume a point by sending its `contextId` to `POST /api/v2/workflows/{workflow-id}/runs/{runId}/resume`. + ## Select outputs and handle large values [#oversized-outputs] `selectedOutputs` can project smaller fields. For a synchronous v2 call, selected values appear in `data.blockOutputs`, separately from `data.output`. From 91a1505cc481f42945ab183ac8b9cd5c1a563372 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 13:31:02 -0800 Subject: [PATCH 13/21] test(mothership): record the run detail's additive version field in the content compatibility baseline --- .../__integration__/content-delivery-compat.baseline.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/mothership/__integration__/content-delivery-compat.baseline.json b/apps/sim/lib/mothership/__integration__/content-delivery-compat.baseline.json index 2f85e7c1452..a9dcd23f9db 100644 --- a/apps/sim/lib/mothership/__integration__/content-delivery-compat.baseline.json +++ b/apps/sim/lib/mothership/__integration__/content-delivery-compat.baseline.json @@ -853,6 +853,7 @@ "startedAt": "