Skip to content

Commit 79dedab

Browse files
Merge remote-tracking branch 'origin/staging' into feat/slack-agent-apis
# Conflicts: # apps/sim/tools/generated/tool-metadata.ts
2 parents b653043 + 2a8fa38 commit 79dedab

54 files changed

Lines changed: 2822 additions & 453 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/integrations/file.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,17 @@ Fetch and parse a file from a URL with optional custom headers.
117117

118118
### File Write
119119

120-
Create a new workspace file, either from text content or from an existing file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv").
120+
Create a new workspace file, either from text content or from an existing file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv") unless overwrite is enabled.
121121

122122
#### Input
123123

124124
| Parameter | Type | Required | Description |
125125
| --------- | ---- | -------- | ----------- |
126-
| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically. |
126+
| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically unless overwrite is enabled. |
127127
| `content` | string | No | The text content to write to the file. Provide exactly one of content or fileInput. |
128128
| `fileInput` | file | No | An existing file to store in the workspace, such as one produced by an earlier tool. Use this for anything that is not text — PDFs, images, audio, archives. Provide exactly one of content or fileInput. |
129129
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from the file extension, or taken from the stored file, if omitted. |
130+
| `overwrite` | boolean | No | Replace the contents of an existing file at the exact target path \(folder and name\) instead of creating a suffixed copy. Creates the file when that path does not exist yet. |
130131

131132
#### Output
132133

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { OrchestrationError } from '@/lib/core/orchestration/types'
7+
8+
const mocks = vi.hoisted(() => ({
9+
authorize: vi.fn(),
10+
getSession: vi.fn(),
11+
resumePage: vi.fn(() => null),
12+
unavailablePage: vi.fn(() => null),
13+
redirect: vi.fn((url: string) => {
14+
throw new Error(`NEXT_REDIRECT:${url}`)
15+
}),
16+
}))
17+
18+
vi.mock('@/lib/auth', () => ({
19+
auth: { api: { getSession: vi.fn() } },
20+
getSession: mocks.getSession,
21+
}))
22+
23+
vi.mock('next/navigation', () => ({
24+
redirect: mocks.redirect,
25+
}))
26+
27+
vi.mock('@/lib/workflows/application/read-paused-workflow-execution', () => ({
28+
readPausedWorkflowExecution: { authorize: mocks.authorize },
29+
}))
30+
31+
vi.mock('@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client', () => ({
32+
default: mocks.resumePage,
33+
}))
34+
35+
vi.mock(
36+
'@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable',
37+
() => ({
38+
ResumeExecutionUnavailable: mocks.unavailablePage,
39+
})
40+
)
41+
42+
import ResumeExecutionPageWrapper from '@/app/(interfaces)/resume/[workflowId]/[executionId]/page'
43+
44+
const PAGE_PARAMS = { workflowId: 'workflow-1', executionId: 'execution-1' }
45+
46+
function pageProps(contextId?: string) {
47+
return {
48+
params: Promise.resolve(PAGE_PARAMS),
49+
searchParams: Promise.resolve(contextId ? { contextId } : {}),
50+
}
51+
}
52+
53+
describe('ResumeExecutionPageWrapper', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
mocks.getSession.mockResolvedValue({
57+
user: { id: 'user-1' },
58+
session: { id: 'session-1' },
59+
})
60+
mocks.authorize.mockResolvedValue(undefined)
61+
})
62+
63+
it('redirects an unauthenticated visitor before any protected lookup', async () => {
64+
mocks.getSession.mockResolvedValueOnce(null)
65+
const callbackPath = '/resume/workflow-1/execution-1?contextId=context-1'
66+
67+
await expect(ResumeExecutionPageWrapper(pageProps('context-1'))).rejects.toThrow(
68+
`NEXT_REDIRECT:/login?callbackUrl=${encodeURIComponent(callbackPath)}`
69+
)
70+
expect(mocks.authorize).not.toHaveBeenCalled()
71+
})
72+
73+
it('authorizes the session without serializing paused execution detail into the page', async () => {
74+
const result = await ResumeExecutionPageWrapper(pageProps('context-1'))
75+
76+
expect(mocks.authorize).toHaveBeenCalledWith({
77+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
78+
input: PAGE_PARAMS,
79+
})
80+
expect(result.props).toMatchObject({
81+
params: PAGE_PARAMS,
82+
initialContextId: 'context-1',
83+
})
84+
expect(result.type).toBe(mocks.resumePage)
85+
expect(result.key).toBe('workflow-1:execution-1:context-1')
86+
expect(result.props).not.toHaveProperty('initialExecutionDetail')
87+
expect(result.props).not.toHaveProperty('canLoadExecution')
88+
})
89+
90+
it.each([
91+
new OrchestrationError('forbidden', 'Insufficient workspace permissions'),
92+
new OrchestrationError('not_found', 'Workflow not found'),
93+
])('renders a data-free concealed state after authorization refusal: %s', async (error) => {
94+
mocks.authorize.mockRejectedValueOnce(error)
95+
96+
const result = await ResumeExecutionPageWrapper(pageProps())
97+
98+
expect(result.type).toBe(mocks.unavailablePage)
99+
expect(result.type).not.toBe(mocks.resumePage)
100+
expect(result.props).toEqual({})
101+
})
102+
103+
it('propagates authorization infrastructure failures', async () => {
104+
const infrastructureError = new Error('database unavailable')
105+
mocks.authorize.mockRejectedValueOnce(infrastructureError)
106+
107+
await expect(ResumeExecutionPageWrapper(pageProps())).rejects.toBe(infrastructureError)
108+
})
109+
})

apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { Metadata } from 'next'
2-
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
2+
import { redirect } from 'next/navigation'
3+
import { getSession } from '@/lib/auth'
4+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
5+
import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution'
6+
import { ResumeExecutionUnavailable } from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable'
37
import ResumeExecutionPage from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client'
48

59
export const metadata: Metadata = {
@@ -30,16 +34,37 @@ export default async function ResumeExecutionPageWrapper({
3034
const initialContextId = Array.isArray(initialContextIdParam)
3135
? initialContextIdParam[0]
3236
: initialContextIdParam
37+
const resumePath = `/resume/${encodeURIComponent(workflowId)}/${encodeURIComponent(executionId)}${
38+
initialContextId ? `?${new URLSearchParams({ contextId: initialContextId })}` : ''
39+
}`
40+
const session = await getSession()
41+
if (!session?.user?.id) {
42+
redirect(`/login?callbackUrl=${encodeURIComponent(resumePath)}`)
43+
}
44+
if (!session.session?.id) throw new Error('Authenticated session is missing its session ID')
3345

34-
const detail = await PauseResumeManager.getPausedExecutionDetail({
35-
workflowId,
36-
executionId,
37-
})
46+
try {
47+
if (!readPausedWorkflowExecution.authorize) {
48+
throw new Error('Paused execution read use case does not expose authorization')
49+
}
50+
await readPausedWorkflowExecution.authorize({
51+
principal: {
52+
kind: 'session',
53+
userId: session.user.id,
54+
sessionId: session.session.id,
55+
},
56+
input: { workflowId, executionId },
57+
})
58+
} catch (error) {
59+
const classified = asOrchestrationError(error)
60+
if (classified?.code !== 'forbidden' && classified?.code !== 'not_found') throw error
61+
return <ResumeExecutionUnavailable />
62+
}
3863

3964
return (
4065
<ResumeExecutionPage
66+
key={`${workflowId}:${executionId}:${initialContextId ?? ''}`}
4167
params={resolvedParams}
42-
initialExecutionDetail={detail ? structuredClone(detail) : null}
4368
initialContextId={initialContextId}
4469
/>
4570
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ChipLink } from '@sim/emcn'
2+
3+
export function ResumeExecutionUnavailable() {
4+
return (
5+
<div className='flex flex-1 items-center justify-center p-6'>
6+
<div className='max-w-[400px] text-center'>
7+
<h1 className='mb-2 text-[var(--text-primary)] text-xl'>Execution Not Found</h1>
8+
<p className='mb-6 text-[var(--text-secondary)] text-sm'>
9+
This execution could not be located or has already completed.
10+
</p>
11+
<ChipLink variant='border' href='/'>
12+
Return Home
13+
</ChipLink>
14+
</div>
15+
</div>
16+
)
17+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { ApiClientError } from '@/lib/api/client/errors'
9+
import type { PausePointWithQueue } from '@/hooks/queries/resume-execution'
10+
11+
const mocks = vi.hoisted(() => ({
12+
pauseContextDetail: vi.fn(),
13+
refetch: vi.fn(),
14+
replace: vi.fn(),
15+
resumeContext: vi.fn(),
16+
resumeExecutionDetail: vi.fn(),
17+
}))
18+
19+
vi.mock('next/navigation', () => ({
20+
useRouter: () => ({ replace: mocks.replace }),
21+
}))
22+
23+
vi.mock('@/hooks/queries/resume-execution', () => ({
24+
resumeKeys: {
25+
execution: (workflowId: string, executionId: string) => [
26+
'resume-execution',
27+
'execution',
28+
workflowId,
29+
executionId,
30+
],
31+
context: (workflowId: string, executionId: string, contextId: string) => [
32+
'resume-execution',
33+
'context',
34+
workflowId,
35+
executionId,
36+
contextId,
37+
],
38+
},
39+
usePauseContextDetail: mocks.pauseContextDetail,
40+
useResumeContext: mocks.resumeContext,
41+
useResumeExecutionDetail: mocks.resumeExecutionDetail,
42+
}))
43+
44+
import ResumeExecutionPage, {
45+
selectInitialResumeContextId,
46+
} from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client'
47+
48+
const params = { workflowId: 'workflow-1', executionId: 'execution-1' }
49+
50+
let container: HTMLDivElement
51+
let queryClient: QueryClient
52+
let root: Root
53+
54+
function apiError(status: number): ApiClientError {
55+
return new ApiClientError({
56+
status,
57+
message: status === 404 ? 'Workflow not found' : 'Request failed',
58+
body: { error: 'Request failed' },
59+
})
60+
}
61+
62+
function renderPage(initialContextId?: string) {
63+
act(() => {
64+
root.render(
65+
<QueryClientProvider client={queryClient}>
66+
<ResumeExecutionPage params={params} initialContextId={initialContextId} />
67+
</QueryClientProvider>
68+
)
69+
})
70+
}
71+
72+
describe('ResumeExecutionPage', () => {
73+
beforeEach(() => {
74+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
75+
container = document.createElement('div')
76+
document.body.appendChild(container)
77+
root = createRoot(container)
78+
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
79+
mocks.pauseContextDetail.mockReturnValue({ data: undefined, isLoading: false })
80+
mocks.resumeContext.mockReturnValue({ mutateAsync: vi.fn() })
81+
mocks.resumeExecutionDetail.mockReturnValue({
82+
data: undefined,
83+
error: null,
84+
isError: false,
85+
isFetching: true,
86+
isLoading: true,
87+
refetch: mocks.refetch,
88+
})
89+
})
90+
91+
afterEach(() => {
92+
act(() => root.unmount())
93+
queryClient.clear()
94+
container.remove()
95+
vi.clearAllMocks()
96+
})
97+
98+
it('renders a concealed state for an absent or newly inaccessible execution', () => {
99+
mocks.resumeExecutionDetail.mockReturnValue({
100+
data: undefined,
101+
error: apiError(404),
102+
isError: true,
103+
isFetching: false,
104+
isLoading: false,
105+
refetch: mocks.refetch,
106+
})
107+
108+
renderPage('context-1')
109+
110+
expect(container.textContent).toContain('Execution Not Found')
111+
expect(container.textContent).not.toContain('Could Not Load Execution')
112+
expect(mocks.pauseContextDetail).toHaveBeenLastCalledWith(
113+
params.workflowId,
114+
params.executionId,
115+
undefined
116+
)
117+
})
118+
119+
it('redirects an expired session back through login', () => {
120+
mocks.resumeExecutionDetail.mockReturnValue({
121+
data: undefined,
122+
error: apiError(401),
123+
isError: true,
124+
isFetching: false,
125+
isLoading: false,
126+
refetch: mocks.refetch,
127+
})
128+
129+
renderPage('context-1')
130+
131+
const callbackPath = '/resume/workflow-1/execution-1?contextId=context-1'
132+
expect(mocks.replace).toHaveBeenCalledWith(
133+
`/login?callbackUrl=${encodeURIComponent(callbackPath)}`
134+
)
135+
expect(container.textContent).toContain('Redirecting to sign in')
136+
})
137+
138+
it('shows a retryable error instead of mislabeling infrastructure failure', () => {
139+
mocks.resumeExecutionDetail.mockReturnValue({
140+
data: undefined,
141+
error: apiError(500),
142+
isError: true,
143+
isFetching: false,
144+
isLoading: false,
145+
refetch: mocks.refetch,
146+
})
147+
148+
renderPage()
149+
150+
expect(container.textContent).toContain('Could Not Load Execution')
151+
expect(container.textContent).not.toContain('Execution Not Found')
152+
const retryButton = Array.from(container.querySelectorAll('button')).find(
153+
(button) => button.textContent === 'Try again'
154+
)
155+
expect(retryButton).toBeDefined()
156+
act(() => retryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
157+
expect(mocks.refetch).toHaveBeenCalledOnce()
158+
})
159+
})
160+
161+
describe('selectInitialResumeContextId', () => {
162+
const pausePoints = [
163+
{ contextId: 'resumed-context', resumeStatus: 'resumed' },
164+
{ contextId: 'paused-context', resumeStatus: 'paused' },
165+
] as PausePointWithQueue[]
166+
167+
it('uses a requested context only when the authorized execution contains it', () => {
168+
expect(selectInitialResumeContextId(pausePoints, 'paused-context')).toBe('paused-context')
169+
expect(selectInitialResumeContextId(pausePoints, 'unknown-context')).toBe('paused-context')
170+
})
171+
172+
it('falls back to the first context when none is paused', () => {
173+
expect(
174+
selectInitialResumeContextId(
175+
[{ contextId: 'first-context', resumeStatus: 'resumed' }] as PausePointWithQueue[],
176+
null
177+
)
178+
).toBe('first-context')
179+
})
180+
})

0 commit comments

Comments
 (0)