Skip to content

Commit 625b528

Browse files
authored
fix(copilot): edit workflow race condition (#1036)
* Fixes * Updates * Build workflow names * Refactor icons * Lint * Update build tool name
1 parent 334e827 commit 625b528

26 files changed

+458
-175
lines changed

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

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
import { getCopilotModel } from '@/lib/copilot/config'
1414
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
1515
import { getBlocksAndToolsTool } from '@/lib/copilot/tools/server-tools/blocks/get-blocks-and-tools'
16+
import { getEnvironmentVariablesTool } from '@/lib/copilot/tools/server-tools/user/get-environment-variables'
17+
import { getOAuthCredentialsTool } from '@/lib/copilot/tools/server-tools/user/get-oauth-credentials'
1618
import { env } from '@/lib/env'
1719
import { createLogger } from '@/lib/logs/console/logger'
1820
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
@@ -90,6 +92,7 @@ const ChatMessageSchema = z.object({
9092
fileAttachments: z.array(FileAttachmentSchema).optional(),
9193
provider: z.string().optional().default('openai'),
9294
conversationId: z.string().optional(),
95+
userWorkflow: z.string().optional(),
9396
})
9497

9598
/**
@@ -207,6 +210,7 @@ export async function POST(req: NextRequest) {
207210
fileAttachments,
208211
provider,
209212
conversationId,
213+
userWorkflow,
210214
} = ChatMessageSchema.parse(body)
211215

212216
// Derive request origin for downstream service
@@ -244,6 +248,7 @@ export async function POST(req: NextRequest) {
244248
depth,
245249
prefetch,
246250
origin: requestOrigin,
251+
hasUserWorkflow: !!userWorkflow,
247252
})
248253

249254
// Handle chat context
@@ -414,17 +419,54 @@ export async function POST(req: NextRequest) {
414419
let prefetchResults: Record<string, any> | undefined
415420
if (effectivePrefetch === true) {
416421
try {
417-
const prefetchResp = await getBlocksAndToolsTool.execute({})
418-
if (prefetchResp.success) {
419-
prefetchResults = { get_blocks_and_tools: prefetchResp.data }
420-
logger.info(`[${tracker.requestId}] Prepared prefetchResults for streaming payload`, {
421-
hasBlocksAndTools: !!prefetchResp.data,
422-
})
422+
const [blocksAndToolsResp, envVarsResp, oauthResp] = await Promise.all([
423+
getBlocksAndToolsTool.execute({}),
424+
getEnvironmentVariablesTool.execute({ userId: authenticatedUserId, workflowId }),
425+
getOAuthCredentialsTool.execute({ userId: authenticatedUserId }),
426+
])
427+
428+
prefetchResults = {}
429+
430+
if (blocksAndToolsResp.success) {
431+
prefetchResults.get_blocks_and_tools = blocksAndToolsResp.data
423432
} else {
424433
logger.warn(`[${tracker.requestId}] Failed to prefetch get_blocks_and_tools`, {
425-
error: prefetchResp.error,
434+
error: blocksAndToolsResp.error,
435+
})
436+
}
437+
438+
if (envVarsResp.success) {
439+
prefetchResults.get_environment_variables = envVarsResp.data
440+
} else {
441+
logger.warn(`[${tracker.requestId}] Failed to prefetch get_environment_variables`, {
442+
error: envVarsResp.error,
443+
})
444+
}
445+
446+
if (oauthResp.success) {
447+
prefetchResults.get_oauth_credentials = oauthResp.data
448+
} else {
449+
logger.warn(`[${tracker.requestId}] Failed to prefetch get_oauth_credentials`, {
450+
error: oauthResp.error,
451+
})
452+
}
453+
454+
if (userWorkflow && typeof userWorkflow === 'string' && userWorkflow.trim().length > 0) {
455+
prefetchResults.get_user_workflow = userWorkflow
456+
const uwLength = userWorkflow.length
457+
const uwPreview = userWorkflow.substring(0, 10000)
458+
logger.info(`[${tracker.requestId}] Included client-provided userWorkflow in prefetch`, {
459+
length: uwLength,
460+
preview: `${uwPreview}${uwLength > 10000 ? '...' : ''}`,
426461
})
427462
}
463+
464+
logger.info(`[${tracker.requestId}] Prepared prefetchResults for streaming payload`, {
465+
hasBlocksAndTools: !!prefetchResults.get_blocks_and_tools,
466+
hasEnvVars: !!prefetchResults.get_environment_variables,
467+
hasOAuthCreds: !!prefetchResults.get_oauth_credentials,
468+
hasUserWorkflow: !!prefetchResults.get_user_workflow,
469+
})
428470
} catch (e) {
429471
logger.error(`[${tracker.requestId}] Error while preparing prefetchResults`, e)
430472
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/auth'
4+
import { createLogger } from '@/lib/logs/console/logger'
5+
import { simAgentClient } from '@/lib/sim-agent'
6+
7+
const logger = createLogger('CopilotToolsCompleteAPI')
8+
9+
const Schema = z.object({
10+
toolId: z.string().min(1),
11+
methodId: z.string().min(1),
12+
success: z.boolean(),
13+
data: z.any().optional(),
14+
error: z.string().optional(),
15+
})
16+
17+
export async function POST(req: NextRequest) {
18+
const requestId = crypto.randomUUID()
19+
const start = Date.now()
20+
21+
try {
22+
const sessionAuth = await authenticateCopilotRequestSessionOnly()
23+
if (!sessionAuth.isAuthenticated) {
24+
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
25+
}
26+
27+
const body = await req.json()
28+
const { toolId, methodId, success, data, error } = Schema.parse(body)
29+
30+
logger.info(`[${requestId}] Forwarding tool completion to sim-agent`, {
31+
toolId,
32+
methodId,
33+
success,
34+
hasData: data !== undefined,
35+
hasError: !!error,
36+
})
37+
38+
const resp = await simAgentClient.makeRequest('/api/complete-tool', {
39+
method: 'POST',
40+
body: { toolId, methodId, success, ...(success ? { data } : { error: error || 'Unknown' }) },
41+
})
42+
43+
const duration = Date.now() - start
44+
logger.info(`[${requestId}] Sim-agent completion response`, {
45+
status: resp.status,
46+
success: resp.success,
47+
duration,
48+
})
49+
50+
return NextResponse.json(resp)
51+
} catch (e) {
52+
logger.error('Failed to forward tool completion', {
53+
error: e instanceof Error ? e.message : 'Unknown error',
54+
})
55+
if (e instanceof z.ZodError) {
56+
return NextResponse.json(
57+
{ success: false, error: e.errors.map((er) => er.message).join(', ') },
58+
{ status: 400 }
59+
)
60+
}
61+
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
62+
}
63+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/auth'
4+
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
5+
import { createLogger } from '@/lib/logs/console/logger'
6+
7+
const logger = createLogger('CopilotWorkflowsEditExecuteAPI')
8+
9+
const Schema = z.object({
10+
operations: z.array(z.object({}).passthrough()),
11+
workflowId: z.string().min(1),
12+
currentUserWorkflow: z.string().optional(),
13+
})
14+
15+
export async function POST(req: NextRequest) {
16+
const requestId = crypto.randomUUID()
17+
const start = Date.now()
18+
19+
try {
20+
const sessionAuth = await authenticateCopilotRequestSessionOnly()
21+
if (!sessionAuth.isAuthenticated) {
22+
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const body = await req.json()
26+
const params = Schema.parse(body)
27+
28+
logger.info(`[${requestId}] Executing edit_workflow (logic-only)`, {
29+
workflowId: params.workflowId,
30+
operationCount: params.operations.length,
31+
hasCurrentUserWorkflow: !!params.currentUserWorkflow,
32+
})
33+
34+
// Execute the server tool WITHOUT emitting completion to sim-agent
35+
const result = await copilotToolRegistry.execute('edit_workflow', params)
36+
37+
const duration = Date.now() - start
38+
logger.info(`[${requestId}] edit_workflow (logic-only) completed`, {
39+
success: result.success,
40+
duration,
41+
})
42+
43+
return NextResponse.json(result, { status: result.success ? 200 : 400 })
44+
} catch (error) {
45+
logger.error('Logic execution failed for edit_workflow', {
46+
error: error instanceof Error ? error.message : 'Unknown error',
47+
})
48+
if (error instanceof z.ZodError) {
49+
return NextResponse.json(
50+
{ success: false, error: error.errors.map((e) => e.message).join(', ') },
51+
{ status: 400 }
52+
)
53+
}
54+
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
55+
}
56+
}

apps/sim/app/api/yaml/diff/create/route.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,22 @@ export async function POST(request: NextRequest) {
201201
const finalResult = result
202202

203203
if (result.success && result.diff?.proposedState) {
204+
// Remove invalid blocks that are missing required properties to avoid canvas warnings
205+
try {
206+
const rawBlocks = result.diff.proposedState.blocks || {}
207+
const sanitizedBlocks: Record<string, any> = {}
208+
Object.entries(rawBlocks).forEach(([id, block]: [string, any]) => {
209+
if (block && typeof block === 'object' && block.type && block.name) {
210+
sanitizedBlocks[id] = block
211+
} else {
212+
logger.warn(`[${requestId}] Dropping invalid proposed block`, { id, block })
213+
}
214+
})
215+
result.diff.proposedState.blocks = sanitizedBlocks
216+
} catch (e) {
217+
logger.warn(`[${requestId}] Failed to sanitize proposed blocks`, e)
218+
}
219+
204220
// First, fix parent-child relationships based on edges
205221
const blocks = result.diff.proposedState.blocks
206222
const edges = result.diff.proposedState.edges || []
@@ -274,6 +290,21 @@ export async function POST(request: NextRequest) {
274290
const blocks = result.blocks
275291
const edges = result.edges || []
276292

293+
// Remove invalid blocks prior to transformation
294+
try {
295+
const sanitized: Record<string, any> = {}
296+
Object.entries(blocks).forEach(([id, block]: [string, any]) => {
297+
if (block && typeof block === 'object' && block.type && block.name) {
298+
sanitized[id] = block
299+
} else {
300+
logger.warn(`[${requestId}] Dropping invalid block in auto-layout response`, { id })
301+
}
302+
})
303+
;(result as any).blocks = sanitized
304+
} catch (e) {
305+
logger.warn(`[${requestId}] Failed to sanitize auto-layout blocks`, e)
306+
}
307+
277308
// Find all loop and parallel blocks
278309
const containerBlocks = Object.values(blocks).filter(
279310
(block: any) => block.type === 'loop' || block.type === 'parallel'

apps/sim/lib/copilot/api.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ export interface SendMessageRequest {
5757
chatId?: string
5858
workflowId?: string
5959
mode?: 'ask' | 'agent'
60-
depth?: -2 | -1 | 0 | 1 | 2 | 3
60+
depth?: 0 | 1 | 2 | 3
6161
prefetch?: boolean
6262
createNewChat?: boolean
6363
stream?: boolean
6464
implicitFeedback?: string
6565
fileAttachments?: MessageFileAttachment[]
6666
abortSignal?: AbortSignal
67+
userWorkflow?: string
6768
}
6869

6970
/**
@@ -103,13 +104,27 @@ export async function sendStreamingMessage(
103104
): Promise<StreamingResponse> {
104105
try {
105106
const { abortSignal, ...requestBody } = request
106-
const response = await fetch('/api/copilot/chat', {
107-
method: 'POST',
108-
headers: { 'Content-Type': 'application/json' },
109-
body: JSON.stringify({ ...requestBody, stream: true }),
110-
signal: abortSignal,
111-
credentials: 'include', // Include cookies for session authentication
112-
})
107+
108+
// Simple, single retry for transient failures
109+
const attempt = async (): Promise<Response> => {
110+
return fetch('/api/copilot/chat', {
111+
method: 'POST',
112+
headers: { 'Content-Type': 'application/json' },
113+
body: JSON.stringify({ ...requestBody, stream: true }),
114+
signal: abortSignal,
115+
credentials: 'include', // Include cookies for session authentication
116+
})
117+
}
118+
119+
let response = await attempt()
120+
121+
// Retry once on likely transient server/network issues (5xx)
122+
if (!response.ok && response.status >= 500 && response.status < 600) {
123+
try {
124+
await new Promise((r) => setTimeout(r, 200))
125+
} catch {}
126+
response = await attempt()
127+
}
113128

114129
if (!response.ok) {
115130
const errorMessage = await handleApiError(response, 'Failed to send streaming message')

apps/sim/lib/copilot/tools/client-tools/build-workflow.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ export class BuildWorkflowClientTool extends BaseTool {
1919
id: BuildWorkflowClientTool.id,
2020
displayConfig: {
2121
states: {
22-
executing: { displayName: 'Building workflow from YAML', icon: 'spinner' },
22+
executing: { displayName: 'Building workflow', icon: 'spinner' },
2323
success: { displayName: 'Built workflow', icon: 'grid2x2Check' },
2424
ready_for_review: { displayName: 'Ready for review', icon: 'grid2x2' },
25-
rejected: { displayName: 'Skipped building workflow', icon: 'skip' },
25+
rejected: { displayName: 'Skipped building workflow', icon: 'circle-slash' },
2626
errored: { displayName: 'Failed to build workflow', icon: 'error' },
2727
aborted: { displayName: 'Aborted building workflow', icon: 'abort' },
28+
accepted: { displayName: 'Built workflow', icon: 'grid2x2Check' },
2829
},
2930
},
3031
schema: {
@@ -74,13 +75,25 @@ export class BuildWorkflowClientTool extends BaseTool {
7475
return { success: false, error: 'yamlContent is required' }
7576
}
7677

77-
// Do not call /api/copilot/methods for build_workflow. Succeed locally and pass through data.
78-
logger.info('build_workflow: performing local success without server call', {
79-
hasDescription: !!description,
80-
yamlLength: yamlContent.length,
78+
// 1) Call logic-only execute route to get build result without emitting completion
79+
const execResp = await fetch('/api/copilot/workflows/build/execute', {
80+
method: 'POST',
81+
headers: { 'Content-Type': 'application/json' },
82+
credentials: 'include',
83+
body: JSON.stringify({ yamlContent, ...(description ? { description } : {}) }),
8184
})
85+
if (!execResp.ok) {
86+
const e = await execResp.json().catch(() => ({}))
87+
options?.onStateChange?.('errored')
88+
return { success: false, error: e?.error || 'Failed to build workflow' }
89+
}
90+
const execResult = await execResp.json()
91+
if (!execResult.success) {
92+
options?.onStateChange?.('errored')
93+
return { success: false, error: execResult.error || 'Server method failed' }
94+
}
8295

83-
// Trigger diff directly
96+
// 2) Update diff from YAML
8497
try {
8598
await useWorkflowDiffStore.getState().setProposedChanges(yamlContent)
8699
logger.info('Diff store updated from build_workflow YAML')
@@ -90,6 +103,21 @@ export class BuildWorkflowClientTool extends BaseTool {
90103
})
91104
}
92105

106+
// 3) Notify completion to agent without re-executing logic
107+
try {
108+
await fetch('/api/copilot/tools/complete', {
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/json' },
111+
credentials: 'include',
112+
body: JSON.stringify({
113+
toolId: toolCall.id,
114+
methodId: 'build_workflow',
115+
success: true,
116+
data: execResult.data,
117+
}),
118+
})
119+
} catch {}
120+
93121
// Transition to ready_for_review for store compatibility
94122
options?.onStateChange?.('success')
95123
options?.onStateChange?.('ready_for_review')
@@ -99,7 +127,7 @@ export class BuildWorkflowClientTool extends BaseTool {
99127
data: {
100128
yamlContent,
101129
...(description ? { description } : {}),
102-
note: 'Build workflow handled on client without server call',
130+
...(execResult?.data ? { data: execResult.data } : {}),
103131
},
104132
}
105133
} catch (error: any) {

0 commit comments

Comments
 (0)