@@ -757,7 +815,7 @@ export default function ResumeExecutionPage({
key={pause.contextId}
variant={pause.contextId === selectedContextId ? 'active' : 'ghost'}
onClick={() => {
- setSelectedContextId(pause.contextId)
+ setSelectedContextIdOverride(pause.contextId)
setError(null)
setMessage(null)
}}
diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.test.ts
new file mode 100644
index 00000000000..9f9310bc17a
--- /dev/null
+++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.test.ts
@@ -0,0 +1,182 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ InsufficientWorkspacePermissionsError,
+ NoWorkspaceAccessError,
+} from '@/lib/core/application'
+
+const mocks = vi.hoisted(() => ({
+ execute: vi.fn(),
+ getSession: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mocks.getSession,
+}))
+
+vi.mock('@/lib/workflows/application/read-paused-workflow-execution', () => ({
+ readPausedWorkflowExecution: {
+ operation: { id: 'workflows.paused_executions.read' },
+ execute: mocks.execute,
+ },
+}))
+
+import { GET } from '@/app/api/resume/[workflowId]/[executionId]/route'
+import { GET as GET_PAUSED_EXECUTION } from '@/app/api/workflows/[id]/paused/[executionId]/route'
+
+const params = { workflowId: 'workflow-1', executionId: 'execution-1' }
+const detail = {
+ id: 'paused-1',
+ workflowId: params.workflowId,
+ executionId: params.executionId,
+ status: 'paused',
+ totalPauseCount: 1,
+ resumedCount: 0,
+ pausedAt: '2026-08-31T12:00:00.000Z',
+ updatedAt: '2026-08-31T12:00:00.000Z',
+ expiresAt: null,
+ metadata: { source: 'human-in-the-loop' },
+ triggerIds: ['trigger-1'],
+ pausePoints: [
+ {
+ contextId: 'context-1',
+ resumeStatus: 'paused',
+ registeredAt: '2026-08-31T12:00:00.000Z',
+ snapshotReady: true,
+ response: { data: { approved: false } },
+ queuePosition: 1,
+ },
+ ],
+ executionSnapshot: { snapshot: '{}', triggerIds: [] },
+ queue: [
+ {
+ id: 'queue-1',
+ pausedExecutionId: 'paused-1',
+ parentExecutionId: params.executionId,
+ newExecutionId: 'execution-2',
+ contextId: 'context-1',
+ resumeInput: { approved: true },
+ status: 'queued',
+ queuedAt: '2026-08-31T12:01:00.000Z',
+ claimedAt: null,
+ completedAt: null,
+ failureReason: null,
+ },
+ ],
+}
+
+function request() {
+ return createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost/api/resume/workflow-1/execution-1'
+ )
+}
+
+function pausedExecutionRequest() {
+ return createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost/api/workflows/workflow-1/paused/execution-1'
+ )
+}
+
+const routeCases = [
+ {
+ name: 'resume detail route',
+ call: () => GET(request(), { params: Promise.resolve(params) }),
+ },
+ {
+ name: 'workflow paused-detail route',
+ call: () =>
+ GET_PAUSED_EXECUTION(pausedExecutionRequest(), {
+ params: Promise.resolve({ id: params.workflowId, executionId: params.executionId }),
+ }),
+ },
+]
+
+describe('GET /api/resume/[workflowId]/[executionId]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getSession.mockResolvedValue({
+ user: { id: 'user-1' },
+ session: { id: 'session-1' },
+ })
+ mocks.execute.mockResolvedValue(detail)
+ })
+
+ it('rejects an unauthenticated request before the application use case', async () => {
+ mocks.getSession.mockResolvedValueOnce(null)
+
+ const response = await GET(request(), { params: Promise.resolve(params) })
+
+ expect(response.status).toBe(401)
+ expect(await response.json()).toMatchObject({ error: 'Unauthorized' })
+ expect(mocks.execute).not.toHaveBeenCalled()
+ })
+
+ it('loads detail through the authorized application use case', async () => {
+ const response = await GET(request(), { params: Promise.resolve(params) })
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual(detail)
+ expect(response.headers.get('Cache-Control')).toBe('private, no-store')
+ expect(mocks.execute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
+ input: params,
+ })
+ )
+ })
+
+ it('maps the sibling route parameter to the same semantic input', async () => {
+ const response = await GET_PAUSED_EXECUTION(pausedExecutionRequest(), {
+ params: Promise.resolve({ id: params.workflowId, executionId: params.executionId }),
+ })
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual(detail)
+ expect(response.headers.get('Cache-Control')).toBe('private, no-store')
+ expect(mocks.execute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
+ input: params,
+ })
+ )
+ })
+
+ it.each(routeCases)('$name conceals cross-workspace denial', async ({ call }) => {
+ mocks.execute.mockRejectedValueOnce(new NoWorkspaceAccessError())
+
+ const response = await call()
+
+ expect(response.status).toBe(404)
+ expect(await response.json()).toEqual({ error: 'Workflow not found' })
+ })
+
+ it.each(routeCases)('$name preserves actionable same-workspace denial', async ({ call }) => {
+ mocks.execute.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
+
+ const response = await call()
+
+ expect(response.status).toBe(403)
+ expect(await response.json()).toEqual({ error: 'Insufficient workspace permissions' })
+ })
+
+ it.each(routeCases)('$name sanitizes unexpected failures', async ({ call }) => {
+ mocks.execute.mockRejectedValueOnce(new Error('database password=secret'))
+
+ const response = await call()
+ const body = await response.json()
+
+ expect(response.status).toBe(500)
+ expect(body).toMatchObject({ error: 'Internal server error' })
+ expect(JSON.stringify(body)).not.toContain('password=secret')
+ })
+})
diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts
index 244da8805c4..950c27ea42c 100644
--- a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts
+++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts
@@ -1,51 +1,29 @@
-import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
import { resumeWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
-import { parseRequest } from '@/lib/api/server'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
-import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
-
-const logger = createLogger('WorkflowResumeExecutionAPI')
+import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
+import { internalWorkflowErrorPolicies, internalWorkflowReadAuth } from '@/lib/workflows/api'
+import { workflowOperations } from '@/lib/workflows/application/operations'
+import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
-export const GET = withRouteHandler(
- async (
- request: NextRequest,
- context: { params: Promise<{ workflowId: string; executionId: string }> }
- ) => {
- const parsed = await parseRequest(resumeWorkflowExecutionContract, request, context)
- if (!parsed.success) return parsed.response
- const { workflowId, executionId } = parsed.data.params
-
- const access = await validateWorkflowAccess(request, workflowId, false)
- if (access.error) {
- return NextResponse.json({ error: access.error.message }, { status: access.error.status })
- }
-
- try {
- const detail = await PauseResumeManager.getPausedExecutionDetail({
- workflowId,
- executionId,
- })
-
- if (!detail) {
- return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 })
- }
-
- return NextResponse.json(detail)
- } catch (error: any) {
- logger.error('Failed to load paused execution detail', {
- workflowId,
- executionId,
- error,
- })
- return NextResponse.json(
- { error: error?.message || 'Failed to load paused execution detail' },
- { status: 500 }
- )
- }
- }
-)
+export const GET = defineInternalJsonRoute({
+ contract: resumeWorkflowExecutionContract,
+ auth: internalWorkflowReadAuth,
+ operation: workflowOperations.readPausedExecution,
+ rateLimit: internalRateLimits.none({
+ reason: 'Preserve existing authenticated resume-detail behavior',
+ }),
+ errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization,
+ mapInput: ({ params }) => ({
+ workflowId: params.workflowId,
+ executionId: params.executionId,
+ }),
+ useCase: readPausedWorkflowExecution,
+ responseHeaders: () => ({ 'Cache-Control': 'private, no-store' }),
+ present: (executionDetail) => ({
+ ...executionDetail,
+ pausePoints: executionDetail.pausePoints.map((pausePoint) => ({ ...pausePoint })),
+ queue: executionDetail.queue.map((queueEntry) => ({ ...queueEntry })),
+ }),
+})
diff --git a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts
index 04d835bba12..916e7c22e15 100644
--- a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts
@@ -1,36 +1,29 @@
-import { type NextRequest, NextResponse } from 'next/server'
import { pausedWorkflowExecutionByIdContract } from '@/lib/api/contracts/workflows'
-import { parseRequest } from '@/lib/api/server'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
-import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
+import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
+import { internalWorkflowErrorPolicies, internalWorkflowReadAuth } from '@/lib/workflows/api'
+import { workflowOperations } from '@/lib/workflows/application/operations'
+import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
-export const GET = withRouteHandler(
- async (
- request: NextRequest,
- context: { params: Promise<{ id: string; executionId: string }> }
- ) => {
- const parsed = await parseRequest(pausedWorkflowExecutionByIdContract, request, context)
- if (!parsed.success) return parsed.response
- const { id: workflowId, executionId } = parsed.data.params
-
- const access = await validateWorkflowAccess(request, workflowId, false)
- if (access.error) {
- return NextResponse.json({ error: access.error.message }, { status: access.error.status })
- }
-
- const detail = await PauseResumeManager.getPausedExecutionDetail({
- workflowId,
- executionId,
- })
-
- if (!detail) {
- return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 })
- }
-
- return NextResponse.json(detail)
- }
-)
+export const GET = defineInternalJsonRoute({
+ contract: pausedWorkflowExecutionByIdContract,
+ auth: internalWorkflowReadAuth,
+ operation: workflowOperations.readPausedExecution,
+ rateLimit: internalRateLimits.none({
+ reason: 'Preserve existing authenticated paused-execution detail behavior',
+ }),
+ errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization,
+ mapInput: ({ params }) => ({
+ workflowId: params.id,
+ executionId: params.executionId,
+ }),
+ useCase: readPausedWorkflowExecution,
+ responseHeaders: () => ({ 'Cache-Control': 'private, no-store' }),
+ present: (executionDetail) => ({
+ ...executionDetail,
+ pausePoints: executionDetail.pausePoints.map((pausePoint) => ({ ...pausePoint })),
+ queue: executionDetail.queue.map((queueEntry) => ({ ...queueEntry })),
+ }),
+})
diff --git a/apps/sim/hooks/queries/resume-execution.test.ts b/apps/sim/hooks/queries/resume-execution.test.ts
new file mode 100644
index 00000000000..71768777862
--- /dev/null
+++ b/apps/sim/hooks/queries/resume-execution.test.ts
@@ -0,0 +1,26 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { ApiClientError } from '@/lib/api/client/errors'
+import { shouldRetryResumeExecutionDetail } from '@/hooks/queries/resume-execution'
+
+function apiError(status: number): ApiClientError {
+ return new ApiClientError({
+ status,
+ message: 'Request failed',
+ body: { error: 'Request failed' },
+ })
+}
+
+describe('shouldRetryResumeExecutionDetail', () => {
+ it.each([401, 403, 404])('does not retry terminal HTTP %s responses', (status) => {
+ expect(shouldRetryResumeExecutionDetail(0, apiError(status))).toBe(false)
+ })
+
+ it('retries an infrastructure failure once', () => {
+ expect(shouldRetryResumeExecutionDetail(0, apiError(500))).toBe(true)
+ expect(shouldRetryResumeExecutionDetail(1, apiError(500))).toBe(false)
+ expect(shouldRetryResumeExecutionDetail(0, new TypeError('network unavailable'))).toBe(true)
+ })
+})
diff --git a/apps/sim/hooks/queries/resume-execution.ts b/apps/sim/hooks/queries/resume-execution.ts
index 9ee3fddd042..2930d9e5636 100644
--- a/apps/sim/hooks/queries/resume-execution.ts
+++ b/apps/sim/hooks/queries/resume-execution.ts
@@ -102,16 +102,17 @@ interface ResumeContextVariables {
input?: unknown
}
+export function shouldRetryResumeExecutionDetail(failureCount: number, error: unknown): boolean {
+ if (isApiClientError(error) && error.status >= 400 && error.status < 500) return false
+ return failureCount < 1
+}
+
/**
* Loads the paused execution detail (all pause points for an execution). The
* contract models pause points loosely (`z.record`); the resume UI works against
* the richer `PausedExecutionDetail` interface, hence the bridging cast.
*/
-export function useResumeExecutionDetail(
- workflowId: string,
- executionId: string,
- initialData?: PausedExecutionDetail
-) {
+export function useResumeExecutionDetail(workflowId: string, executionId: string) {
return useQuery({
queryKey: resumeKeys.execution(workflowId, executionId),
queryFn: async ({ signal }): Promise
=> {
@@ -124,7 +125,7 @@ export function useResumeExecutionDetail(
},
enabled: Boolean(workflowId && executionId),
staleTime: RESUME_EXECUTION_DETAIL_STALE_TIME,
- initialData,
+ retry: shouldRetryResumeExecutionDetail,
})
}
diff --git a/apps/sim/lib/workflows/application/operations.test.ts b/apps/sim/lib/workflows/application/operations.test.ts
index c79db22ca80..b978a3e3bcd 100644
--- a/apps/sim/lib/workflows/application/operations.test.ts
+++ b/apps/sim/lib/workflows/application/operations.test.ts
@@ -134,4 +134,14 @@ describe('workflow operation registry', () => {
expect(operation.id).toMatch(/^workflows\.manual\.execute/)
}
})
+
+ it('protects paused execution detail as a workflow read', () => {
+ expect(workflowOperations.readPausedExecution).toMatchObject({
+ id: 'workflows.paused_executions.read',
+ minimumRole: 'read',
+ workspaceApiKey: 'allow',
+ principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'],
+ delegatedServices: ['copilot'],
+ })
+ })
})
diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts
index 87591f517bc..897c626b961 100644
--- a/apps/sim/lib/workflows/application/operations.ts
+++ b/apps/sim/lib/workflows/application/operations.ts
@@ -348,6 +348,12 @@ export const workflowOperations = {
workspaceApiKey: 'allow',
...ALL_WORKFLOW_PRINCIPAL_POLICY,
}),
+ readPausedExecution: defineWorkspaceOperation({
+ id: 'workflows.paused_executions.read',
+ minimumRole: 'read',
+ workspaceApiKey: 'allow',
+ ...ALL_WORKFLOW_PRINCIPAL_POLICY,
+ }),
/**
* Downloading one file a run produced. Separate from `readRun` because it
* hands out bytes and records a `FILE_DOWNLOADED` audit event, which reading
diff --git a/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts b/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts
new file mode 100644
index 00000000000..1786ba37513
--- /dev/null
+++ b/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts
@@ -0,0 +1,218 @@
+/**
+ * @vitest-environment node
+ */
+import type { Principal } from '@sim/auth/principal'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ getPausedExecutionDetail: vi.fn(),
+ resolvePermission: vi.fn(),
+ resolveWorkflowContext: vi.fn(),
+}))
+
+vi.mock('@sim/platform-authz/workspace', () => ({
+ permissionSatisfies: (actual: string | null, required: string) => {
+ const rank = { read: 1, write: 2, admin: 3 } as const
+ return (
+ actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank]
+ )
+ },
+ resolveEffectiveWorkspacePermission: mocks.resolvePermission,
+}))
+
+vi.mock('@/lib/workflows/application/context', () => ({
+ resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext,
+}))
+
+vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
+ PauseResumeManager: {
+ getPausedExecutionDetail: mocks.getPausedExecutionDetail,
+ },
+}))
+
+import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution'
+
+const workflowContext = {
+ workflowId: 'workflow-1',
+ workflow: { id: 'workflow-1' },
+ workspaceId: 'workspace-1',
+ workspaceOrganizationId: null,
+ allowPersonalApiKeys: true,
+ billedAccountUserId: 'billing-owner-1',
+}
+
+const detail = {
+ id: 'paused-1',
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+}
+
+const allowedPrincipals: Principal[] = [
+ { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
+ { kind: 'personal_api_key', userId: 'user-1', keyId: 'personal-key-1' },
+ { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'workspace-key-1' },
+ {
+ kind: 'delegated',
+ serviceId: 'copilot',
+ subjectUserId: 'user-1',
+ workspaceId: 'workspace-1',
+ delegationId: 'copilot-delegation-1',
+ audience: 'sim:workflows',
+ issuedAt: new Date('2026-01-01T00:00:00.000Z'),
+ expiresAt: new Date('2999-01-01T00:00:00.000Z'),
+ },
+]
+
+describe('readPausedWorkflowExecution', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.resolvePermission.mockResolvedValue('read')
+ mocks.resolveWorkflowContext.mockResolvedValue(workflowContext)
+ mocks.getPausedExecutionDetail.mockResolvedValue(detail)
+ })
+
+ it.each(allowedPrincipals)(
+ 'authorizes $kind before loading paused execution detail',
+ async (principal) => {
+ const result = await readPausedWorkflowExecution.execute({
+ principal,
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+
+ expect(result).toBe(detail)
+ expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ workflowId: 'workflow-1' })
+ expect(mocks.getPausedExecutionDetail).toHaveBeenCalledWith({
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ })
+ }
+ )
+
+ it('finishes session authorization before loading paused execution detail', async () => {
+ await readPausedWorkflowExecution.execute({
+ principal: allowedPrincipals[0],
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+
+ expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.getPausedExecutionDetail.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('supports an authorization-only preflight without loading paused execution detail', async () => {
+ expect(readPausedWorkflowExecution.authorize).toBeTypeOf('function')
+
+ await readPausedWorkflowExecution.authorize?.({
+ principal: allowedPrincipals[0],
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+
+ expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ workflowId: 'workflow-1' })
+ expect(mocks.resolvePermission).toHaveBeenCalled()
+ expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled()
+ })
+
+ it('rejects executor delegation before canonical lookup', async () => {
+ const principal: Principal = {
+ kind: 'delegated',
+ serviceId: 'executor',
+ workspaceId: 'workspace-1',
+ delegationId: 'execution-delegation-1',
+ audience: 'sim:workflows',
+ issuedAt: new Date('2026-01-01T00:00:00.000Z'),
+ expiresAt: new Date('2999-01-01T00:00:00.000Z'),
+ delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' },
+ }
+
+ await expect(
+ readPausedWorkflowExecution.execute({
+ principal,
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+ ).rejects.toMatchObject({ name: 'DelegatedServiceAuthorizationError' })
+ expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled()
+ expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled()
+ })
+
+ it('rejects a disallowed principal before canonical lookup', async () => {
+ const principal: Principal = {
+ kind: 'system',
+ serviceId: 'internal',
+ workspaceId: 'workspace-1',
+ workflowId: 'workflow-1',
+ }
+
+ await expect(
+ readPausedWorkflowExecution.execute({
+ principal,
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+ ).rejects.toMatchObject({ name: 'PrincipalKindAuthorizationError' })
+ expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled()
+ expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled()
+ })
+
+ it('rejects a workspace key outside the canonical workspace before loading detail', async () => {
+ await expect(
+ readPausedWorkflowExecution.execute({
+ principal: {
+ kind: 'workspace_api_key',
+ workspaceId: 'workspace-2',
+ keyId: 'workspace-key-2',
+ },
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+ ).rejects.toMatchObject({ code: 'forbidden' })
+ expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled()
+ })
+
+ it('rejects a session without current workspace access before loading detail', async () => {
+ mocks.resolvePermission.mockResolvedValueOnce(null)
+
+ await expect(
+ readPausedWorkflowExecution.execute({
+ principal: allowedPrincipals[0],
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+ ).rejects.toMatchObject({ name: 'NoWorkspaceAccessError' })
+ expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled()
+ })
+
+ it('enforces the workspace personal-key policy before loading detail', async () => {
+ mocks.resolveWorkflowContext.mockResolvedValueOnce({
+ ...workflowContext,
+ allowPersonalApiKeys: false,
+ })
+
+ await expect(
+ readPausedWorkflowExecution.execute({
+ principal: allowedPrincipals[1],
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+ ).rejects.toMatchObject({ name: 'PersonalApiKeysDisabledError' })
+ expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled()
+ })
+
+ it('returns a semantic not-found error when no paused execution matches', async () => {
+ mocks.getPausedExecutionDetail.mockResolvedValueOnce(null)
+
+ await expect(
+ readPausedWorkflowExecution.execute({
+ principal: allowedPrincipals[0],
+ input: { workflowId: 'workflow-1', executionId: 'missing-execution' },
+ })
+ ).rejects.toMatchObject({ code: 'not_found', message: 'Paused execution not found' })
+ })
+
+ it('propagates manager infrastructure failures', async () => {
+ const infrastructureError = new Error('database unavailable')
+ mocks.getPausedExecutionDetail.mockRejectedValueOnce(infrastructureError)
+
+ await expect(
+ readPausedWorkflowExecution.execute({
+ principal: allowedPrincipals[0],
+ input: { workflowId: 'workflow-1', executionId: 'execution-1' },
+ })
+ ).rejects.toBe(infrastructureError)
+ })
+})
diff --git a/apps/sim/lib/workflows/application/read-paused-workflow-execution.ts b/apps/sim/lib/workflows/application/read-paused-workflow-execution.ts
new file mode 100644
index 00000000000..958bfdea234
--- /dev/null
+++ b/apps/sim/lib/workflows/application/read-paused-workflow-execution.ts
@@ -0,0 +1,24 @@
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case'
+import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context'
+import { workflowOperations } from '@/lib/workflows/application/operations'
+import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
+
+export interface ReadPausedWorkflowExecutionInput {
+ workflowId: string
+ executionId: string
+}
+
+export const readPausedWorkflowExecution = defineAuthorizedWorkflowUseCase({
+ operation: workflowOperations.readPausedExecution,
+ resolveContext: ({ input }: { input: ReadPausedWorkflowExecutionInput }) =>
+ resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }),
+ async execute({ context, input }) {
+ const detail = await PauseResumeManager.getPausedExecutionDetail({
+ workflowId: context.workflowId,
+ executionId: input.executionId,
+ })
+ if (!detail) throw new OrchestrationError('not_found', 'Paused execution not found')
+ return detail
+ },
+})