Skip to content

Commit 15fc6ab

Browse files
authored
fix(workflows): keep long-running calls active (#7579)
* fix(workflows): keep long-running calls active * chore(helm): bump chart version * fix(workflows): preserve stream compatibility * refactor(streaming): import SSE helpers directly
1 parent 651586e commit 15fc6ab

31 files changed

Lines changed: 1281 additions & 310 deletions

File tree

apps/docs/openapi-v2-workflows.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2502,7 +2502,7 @@
25022502
"post": {
25032503
"operationId": "executeWorkflowV2",
25042504
"summary": "Execute Workflow",
2505-
"description": "Execute the deployment; `run.source: \"manual\"` uses draft state. Manual runs require a personal key or OAuth write access; workspace keys, anonymous callers, and async are rejected. Start at a runnable trigger, or resume from `sourceRunId` using the same-workflow snapshot. Public deployments allow anonymous sync or streaming; async requires credentials. Sync timeouts return `200` with failed status and `TIMEOUT`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.\n\nOAuth scope: `api:write`.",
2505+
"description": "Execute a deployment or use `run.source: \"manual\"` for draft state. Manual runs require personal or OAuth write access and reject workspace keys, anonymous callers, and async. Start at a trigger or resume from same-workflow `sourceRunId`. Public deployments allow anonymous sync or streaming. Request `application/x-ndjson` for 15-second heartbeats and final resource. Timeouts return `200` with failed status and `TIMEOUT`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.\n\nOAuth scope: `api:write`.",
25062506
"x-sim-operation": "workflows.execute",
25072507
"x-oauth-scope": "api:write",
25082508
"tags": ["Workflows"],
@@ -2566,7 +2566,7 @@
25662566
},
25672567
"responses": {
25682568
"200": {
2569-
"description": "A synchronous run result or Server-Sent Event stream.",
2569+
"description": "A synchronous run result, heartbeat-delimited NDJSON result stream, or Server-Sent Event stream.",
25702570
"headers": {
25712571
"X-Run-Id": {
25722572
"$ref": "#/components/headers/X-Run-Id"
@@ -2587,6 +2587,11 @@
25872587
"$ref": "#/components/schemas/ExecuteWorkflowSyncResponse"
25882588
}
25892589
},
2590+
"application/x-ndjson": {
2591+
"schema": {
2592+
"type": "string"
2593+
}
2594+
},
25902595
"text/event-stream": {
25912596
"schema": {
25922597
"type": "string"

apps/sim/app/api/copilot/chat/stream/route.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ vi.mock('@/lib/copilot/request/session', () => ({
3838
}),
3939
encodeSSEEnvelope: (event: Record<string, unknown>) =>
4040
new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`),
41-
encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`),
4241
SSE_RESPONSE_HEADERS: {
4342
'Content-Type': 'text/event-stream',
4443
},

apps/sim/app/api/copilot/chat/stream/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel'
2222
import {
2323
checkForReplayGap,
2424
createEvent,
25-
encodeSSEComment,
2625
encodeSSEEnvelope,
2726
readEvents,
2827
readFilePreviewSessions,
2928
SSE_RESPONSE_HEADERS,
3029
} from '@/lib/copilot/request/session'
3130
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
31+
import { encodeSSEComment } from '@/lib/core/utils/sse'
3232
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3333

3434
export const maxDuration = 3600

apps/sim/app/api/mcp/serve/[serverId]/route.test.ts

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ vi.mock('@/lib/auth/internal', () => ({
9595
}))
9696

9797
vi.mock('@/lib/core/execution-limits', () => ({
98-
getMaxExecutionTimeout: () => 10_000,
98+
getMaxExecutionTimeout: () => 60_000,
9999
}))
100100

101101
vi.mock('@/lib/workflows/executor/execute-service', () => ({
@@ -309,7 +309,10 @@ describe('MCP Serve Route', () => {
309309

310310
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
311311
method: 'POST',
312-
headers: { 'X-API-Key': 'pk_test_123' },
312+
headers: {
313+
'X-API-Key': 'pk_test_123',
314+
Accept: 'application/json, text/event-stream;q=0',
315+
},
313316
body: JSON.stringify({
314317
jsonrpc: '2.0',
315318
id: 1,
@@ -320,6 +323,7 @@ describe('MCP Serve Route', () => {
320323
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
321324

322325
expect(response.status).toBe(200)
326+
expect(response.headers.get('content-type')).toContain('application/json')
323327
expect(mockExecuteWorkflowService).toHaveBeenCalledTimes(1)
324328
expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
325329
expect.objectContaining({
@@ -338,6 +342,153 @@ describe('MCP Serve Route', () => {
338342
})
339343
})
340344

345+
it('keeps a Streamable HTTP tool call active and ends with its JSON-RPC response', async () => {
346+
vi.useFakeTimers()
347+
try {
348+
dbChainMockFns.limit
349+
.mockResolvedValueOnce([
350+
{
351+
id: 'server-1',
352+
name: 'Public Server',
353+
workspaceId: 'ws-1',
354+
isPublic: true,
355+
createdBy: 'owner-1',
356+
},
357+
])
358+
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
359+
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
360+
361+
let finishExecution!: (result: unknown) => void
362+
mockExecuteWorkflowService.mockReturnValueOnce(
363+
new Promise((resolve) => {
364+
finishExecution = resolve
365+
})
366+
)
367+
368+
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
369+
method: 'POST',
370+
headers: { accept: 'application/json, text/event-stream' },
371+
body: JSON.stringify({
372+
jsonrpc: '2.0',
373+
id: 1,
374+
method: 'tools/call',
375+
params: { name: 'tool_a', arguments: { q: 'test' } },
376+
}),
377+
})
378+
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
379+
380+
expect(response.status).toBe(200)
381+
expect(response.headers.get('content-type')).toContain('text/event-stream')
382+
if (!response.body) throw new Error('Expected MCP event stream')
383+
const reader = response.body.getReader()
384+
const decoder = new TextDecoder()
385+
expect(decoder.decode((await reader.read()).value)).toBe(': keepalive\n\n')
386+
await vi.advanceTimersByTimeAsync(15_000)
387+
expect(decoder.decode((await reader.read()).value)).toBe(': keepalive\n\n')
388+
389+
finishExecution({
390+
ok: true,
391+
executionId: 'exec-1',
392+
workflowId: 'wf-1',
393+
status: 'completed',
394+
aborted: null,
395+
output: { ok: true },
396+
error: null,
397+
hasResponseBlock: false,
398+
resolvedSecretTraceProvenance: createResolvedSecretTraceProvenance('owner-1'),
399+
})
400+
401+
const event = decoder.decode((await reader.read()).value)
402+
expect(JSON.parse(event.replace(/^data: /, '').trim())).toMatchObject({
403+
jsonrpc: '2.0',
404+
id: 1,
405+
result: { content: [{ type: 'text' }], isError: false },
406+
})
407+
expect((await reader.read()).done).toBe(true)
408+
} finally {
409+
vi.useRealTimers()
410+
}
411+
})
412+
413+
it('serves metadata when standalone SSE GET is explicitly rejected', async () => {
414+
dbChainMockFns.limit.mockResolvedValueOnce([
415+
{
416+
id: 'server-1',
417+
name: 'Public Server',
418+
workspaceId: 'ws-1',
419+
isPublic: true,
420+
createdBy: 'owner-1',
421+
},
422+
])
423+
424+
const request = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
425+
headers: { accept: 'application/json, text/event-stream;q=0' },
426+
})
427+
const response = await GET(request, { params: Promise.resolve({ serverId: 'server-1' }) })
428+
429+
expect(response.status).toBe(200)
430+
expect(await response.json()).toMatchObject({
431+
name: 'Public Server',
432+
capabilities: { tools: {} },
433+
})
434+
})
435+
436+
it('cancels the workflow when an MCP event-stream consumer disconnects', async () => {
437+
dbChainMockFns.limit
438+
.mockResolvedValueOnce([
439+
{
440+
id: 'server-1',
441+
name: 'Public Server',
442+
workspaceId: 'ws-1',
443+
isPublic: true,
444+
createdBy: 'owner-1',
445+
},
446+
])
447+
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
448+
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
449+
450+
let executionSignal: AbortSignal | undefined
451+
mockExecuteWorkflowService.mockImplementationOnce(
452+
({ abortSignal }: { abortSignal: AbortSignal }) =>
453+
new Promise((resolve) => {
454+
executionSignal = abortSignal
455+
const finish = () =>
456+
resolve({
457+
ok: true,
458+
executionId: 'exec-1',
459+
workflowId: 'wf-1',
460+
status: 'cancelled',
461+
aborted: 'client',
462+
output: undefined,
463+
error: { message: 'Client cancelled request', code: 'CANCELLED' },
464+
hasResponseBlock: false,
465+
})
466+
if (abortSignal.aborted) finish()
467+
else abortSignal.addEventListener('abort', finish, { once: true })
468+
})
469+
)
470+
471+
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
472+
method: 'POST',
473+
headers: { accept: 'application/json, text/event-stream' },
474+
body: JSON.stringify({
475+
jsonrpc: '2.0',
476+
id: 1,
477+
method: 'tools/call',
478+
params: { name: 'tool_a', arguments: { q: 'test' } },
479+
}),
480+
})
481+
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
482+
if (!response.body) throw new Error('Expected MCP event stream')
483+
const reader = response.body.getReader()
484+
await reader.read()
485+
await vi.waitFor(() => expect(executionSignal).toBeDefined())
486+
487+
await reader.cancel('client disconnected')
488+
489+
expect(executionSignal?.aborted).toBe(true)
490+
})
491+
341492
it('rejects a personal api key when the workspace disallows personal api keys', async () => {
342493
dbChainMockFns.limit.mockResolvedValueOnce([
343494
{

0 commit comments

Comments
 (0)