mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(copilot): incremental edits (#891)
* v1 * Incremental edits * Lint * Remove dev env * Fix tests * Lint
This commit is contained in:
committed by
GitHub
parent
05e689bc60
commit
f94258ef83
@@ -354,7 +354,14 @@ describe('Copilot Methods API Route', () => {
|
||||
86400
|
||||
)
|
||||
expect(mockRedisGet).toHaveBeenCalledWith('tool_call:tool-call-123')
|
||||
expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', { key: 'value' })
|
||||
expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', {
|
||||
key: 'value',
|
||||
confirmationMessage: 'User approved',
|
||||
fullData: {
|
||||
message: 'User approved',
|
||||
status: 'accepted',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle tool execution with interrupt - user rejection', async () => {
|
||||
@@ -613,6 +620,10 @@ describe('Copilot Methods API Route', () => {
|
||||
expect(mockToolRegistryExecute).toHaveBeenCalledWith('no_op', {
|
||||
existing: 'param',
|
||||
confirmationMessage: 'Confirmation message',
|
||||
fullData: {
|
||||
message: 'Confirmation message',
|
||||
status: 'accepted',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ async function addToolToRedis(toolCallId: string): Promise<void> {
|
||||
*/
|
||||
async function pollRedisForTool(
|
||||
toolCallId: string
|
||||
): Promise<{ status: NotificationStatus; message?: string } | null> {
|
||||
): Promise<{ status: NotificationStatus; message?: string; fullData?: any } | null> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
logger.warn('pollRedisForTool: Redis client not available')
|
||||
@@ -86,12 +86,14 @@ async function pollRedisForTool(
|
||||
|
||||
let status: NotificationStatus | null = null
|
||||
let message: string | undefined
|
||||
let fullData: any = null
|
||||
|
||||
// Try to parse as JSON (new format), fallback to string (old format)
|
||||
try {
|
||||
const parsedData = JSON.parse(redisValue)
|
||||
status = parsedData.status as NotificationStatus
|
||||
message = parsedData.message || undefined
|
||||
fullData = parsedData // Store the full parsed data
|
||||
} catch {
|
||||
// Fallback to old format (direct status string)
|
||||
status = redisValue as NotificationStatus
|
||||
@@ -138,7 +140,7 @@ async function pollRedisForTool(
|
||||
})
|
||||
}
|
||||
|
||||
return { status, message }
|
||||
return { status, message, fullData }
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
@@ -163,9 +165,13 @@ async function pollRedisForTool(
|
||||
* Handle tool calls that require user interruption/approval
|
||||
* Returns { approved: boolean, rejected: boolean, error?: boolean, message?: string } to distinguish between rejection, timeout, and error
|
||||
*/
|
||||
async function interruptHandler(
|
||||
toolCallId: string
|
||||
): Promise<{ approved: boolean; rejected: boolean; error?: boolean; message?: string }> {
|
||||
async function interruptHandler(toolCallId: string): Promise<{
|
||||
approved: boolean
|
||||
rejected: boolean
|
||||
error?: boolean
|
||||
message?: string
|
||||
fullData?: any
|
||||
}> {
|
||||
if (!toolCallId) {
|
||||
logger.error('interruptHandler: No tool call ID provided')
|
||||
return { approved: false, rejected: false, error: true, message: 'No tool call ID provided' }
|
||||
@@ -185,31 +191,31 @@ async function interruptHandler(
|
||||
return { approved: false, rejected: false }
|
||||
}
|
||||
|
||||
const { status, message } = result
|
||||
const { status, message, fullData } = result
|
||||
|
||||
if (status === 'rejected') {
|
||||
logger.info('Tool execution rejected by user', { toolCallId, message })
|
||||
return { approved: false, rejected: true, message }
|
||||
return { approved: false, rejected: true, message, fullData }
|
||||
}
|
||||
|
||||
if (status === 'accepted') {
|
||||
logger.info('Tool execution approved by user', { toolCallId, message })
|
||||
return { approved: true, rejected: false, message }
|
||||
return { approved: true, rejected: false, message, fullData }
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
logger.error('Tool execution failed with error', { toolCallId, message })
|
||||
return { approved: false, rejected: false, error: true, message }
|
||||
return { approved: false, rejected: false, error: true, message, fullData }
|
||||
}
|
||||
|
||||
if (status === 'background') {
|
||||
logger.info('Tool execution moved to background', { toolCallId, message })
|
||||
return { approved: true, rejected: false, message }
|
||||
return { approved: true, rejected: false, message, fullData }
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
logger.info('Tool execution completed successfully', { toolCallId, message })
|
||||
return { approved: true, rejected: false, message }
|
||||
return { approved: true, rejected: false, message, fullData }
|
||||
}
|
||||
|
||||
logger.warn('Unexpected tool call status', { toolCallId, status, message })
|
||||
@@ -326,7 +332,7 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
// Handle interrupt flow
|
||||
const { approved, rejected, error, message } = await interruptHandler(toolCallId)
|
||||
const { approved, rejected, error, message, fullData } = await interruptHandler(toolCallId)
|
||||
|
||||
if (rejected) {
|
||||
logger.info(`[${requestId}] Tool execution rejected by user`, {
|
||||
@@ -371,10 +377,13 @@ export async function POST(req: NextRequest) {
|
||||
message,
|
||||
})
|
||||
|
||||
// For noop tool, pass the confirmation message as a parameter
|
||||
if (methodId === 'no_op' && message) {
|
||||
// For tools that need confirmation data, pass the message and/or fullData as parameters
|
||||
if (message) {
|
||||
params.confirmationMessage = message
|
||||
}
|
||||
if (fullData) {
|
||||
params.fullData = fullData
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the tool directly via registry
|
||||
|
||||
@@ -70,6 +70,16 @@ export async function POST(request: NextRequest) {
|
||||
// Note: This endpoint is stateless, so we need to get this from the request
|
||||
const currentWorkflowState = (body as any).currentWorkflowState
|
||||
|
||||
// Ensure currentWorkflowState has all required properties with proper defaults if provided
|
||||
if (currentWorkflowState) {
|
||||
if (!currentWorkflowState.loops) {
|
||||
currentWorkflowState.loops = {}
|
||||
}
|
||||
if (!currentWorkflowState.parallels) {
|
||||
currentWorkflowState.parallels = {}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Creating diff from YAML`, {
|
||||
contentLength: yamlContent.length,
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
|
||||
@@ -24,8 +24,8 @@ const MergeDiffRequestSchema = z.object({
|
||||
proposedState: z.object({
|
||||
blocks: z.record(z.any()),
|
||||
edges: z.array(z.any()),
|
||||
loops: z.record(z.any()),
|
||||
parallels: z.record(z.any()),
|
||||
loops: z.record(z.any()).optional(),
|
||||
parallels: z.record(z.any()).optional(),
|
||||
}),
|
||||
diffAnalysis: z.any().optional(),
|
||||
metadata: z.object({
|
||||
@@ -50,6 +50,14 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const { existingDiff, yamlContent, diffAnalysis, options } = MergeDiffRequestSchema.parse(body)
|
||||
|
||||
// Ensure existingDiff.proposedState has all required properties with proper defaults
|
||||
if (!existingDiff.proposedState.loops) {
|
||||
existingDiff.proposedState.loops = {}
|
||||
}
|
||||
if (!existingDiff.proposedState.parallels) {
|
||||
existingDiff.proposedState.parallels = {}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Merging diff from YAML`, {
|
||||
contentLength: yamlContent.length,
|
||||
existingBlockCount: Object.keys(existingDiff.proposedState.blocks).length,
|
||||
|
||||
286
apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts
Normal file
286
apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Get User Workflow Tool - Client-side implementation
|
||||
*/
|
||||
|
||||
import { BaseTool } from '@/lib/copilot/tools/base-tool'
|
||||
import type {
|
||||
CopilotToolCall,
|
||||
ToolExecuteResult,
|
||||
ToolExecutionOptions,
|
||||
ToolMetadata,
|
||||
} from '@/lib/copilot/tools/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface GetUserWorkflowParams {
|
||||
workflowId?: string
|
||||
includeMetadata?: boolean
|
||||
}
|
||||
|
||||
export class GetUserWorkflowTool extends BaseTool {
|
||||
static readonly id = 'get_user_workflow'
|
||||
|
||||
metadata: ToolMetadata = {
|
||||
id: GetUserWorkflowTool.id,
|
||||
displayConfig: {
|
||||
states: {
|
||||
executing: {
|
||||
displayName: 'Analyzing your workflow',
|
||||
icon: 'spinner',
|
||||
},
|
||||
accepted: {
|
||||
displayName: 'Analyzing your workflow',
|
||||
icon: 'spinner',
|
||||
},
|
||||
success: {
|
||||
displayName: 'Workflow analyzed',
|
||||
icon: 'workflow',
|
||||
},
|
||||
rejected: {
|
||||
displayName: 'Skipped workflow analysis',
|
||||
icon: 'skip',
|
||||
},
|
||||
errored: {
|
||||
displayName: 'Failed to analyze workflow',
|
||||
icon: 'error',
|
||||
},
|
||||
aborted: {
|
||||
displayName: 'Aborted workflow analysis',
|
||||
icon: 'abort',
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
name: GetUserWorkflowTool.id,
|
||||
description: 'Get the current workflow state as JSON',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The ID of the workflow to fetch (optional, uses active workflow if not provided)',
|
||||
},
|
||||
includeMetadata: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to include workflow metadata',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
requiresInterrupt: false, // Client tools handle their own interrupts
|
||||
stateMessages: {
|
||||
success: 'Successfully retrieved workflow',
|
||||
error: 'Failed to retrieve workflow',
|
||||
rejected: 'User chose to skip workflow retrieval',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the tool - fetch the workflow from stores and write to Redis
|
||||
*/
|
||||
async execute(
|
||||
toolCall: CopilotToolCall,
|
||||
options?: ToolExecutionOptions
|
||||
): Promise<ToolExecuteResult> {
|
||||
const logger = createLogger('GetUserWorkflowTool')
|
||||
|
||||
logger.info('Starting client tool execution', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
})
|
||||
|
||||
try {
|
||||
// Parse parameters
|
||||
const rawParams = toolCall.parameters || toolCall.input || {}
|
||||
const params = rawParams as GetUserWorkflowParams
|
||||
|
||||
// Get workflow ID - use provided or active workflow
|
||||
let workflowId = params.workflowId
|
||||
if (!workflowId) {
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
if (!activeWorkflowId) {
|
||||
options?.onStateChange?.('errored')
|
||||
return {
|
||||
success: false,
|
||||
error: 'No active workflow found',
|
||||
}
|
||||
}
|
||||
workflowId = activeWorkflowId
|
||||
}
|
||||
|
||||
logger.info('Fetching user workflow from stores', {
|
||||
workflowId,
|
||||
includeMetadata: params.includeMetadata,
|
||||
})
|
||||
|
||||
// Try to get workflow from diff/preview store first, then main store
|
||||
let workflowState: any = null
|
||||
|
||||
// Check diff store first
|
||||
const diffStore = useWorkflowDiffStore.getState()
|
||||
if (diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0) {
|
||||
workflowState = diffStore.diffWorkflow
|
||||
logger.info('Using workflow from diff/preview store', { workflowId })
|
||||
} else {
|
||||
// Get the actual workflow state from the workflow store
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const fullWorkflowState = workflowStore.getWorkflowState()
|
||||
|
||||
if (!fullWorkflowState || !fullWorkflowState.blocks) {
|
||||
// Fallback to workflow registry metadata if no workflow state
|
||||
const workflowRegistry = useWorkflowRegistry.getState()
|
||||
const workflow = workflowRegistry.workflows[workflowId]
|
||||
|
||||
if (!workflow) {
|
||||
options?.onStateChange?.('errored')
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow ${workflowId} not found in any store`,
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('No workflow state found, using workflow metadata only', { workflowId })
|
||||
workflowState = workflow
|
||||
} else {
|
||||
workflowState = fullWorkflowState
|
||||
logger.info('Using workflow state from workflow store', {
|
||||
workflowId,
|
||||
blockCount: Object.keys(fullWorkflowState.blocks || {}).length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure workflow state has all required properties with proper defaults
|
||||
if (workflowState) {
|
||||
if (!workflowState.loops) {
|
||||
workflowState.loops = {}
|
||||
}
|
||||
if (!workflowState.parallels) {
|
||||
workflowState.parallels = {}
|
||||
}
|
||||
if (!workflowState.edges) {
|
||||
workflowState.edges = []
|
||||
}
|
||||
if (!workflowState.blocks) {
|
||||
workflowState.blocks = {}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Validating workflow state', {
|
||||
workflowId,
|
||||
hasWorkflowState: !!workflowState,
|
||||
hasBlocks: !!workflowState?.blocks,
|
||||
workflowStateType: typeof workflowState,
|
||||
})
|
||||
|
||||
if (!workflowState || !workflowState.blocks) {
|
||||
logger.error('Workflow state validation failed', {
|
||||
workflowId,
|
||||
workflowState: workflowState,
|
||||
hasBlocks: !!workflowState?.blocks,
|
||||
})
|
||||
options?.onStateChange?.('errored')
|
||||
return {
|
||||
success: false,
|
||||
error: 'Workflow state is empty or invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Include metadata if requested and available
|
||||
if (params.includeMetadata && workflowState.metadata) {
|
||||
// Metadata is already included in the workflow state
|
||||
}
|
||||
|
||||
logger.info('Successfully fetched user workflow from stores', {
|
||||
workflowId,
|
||||
blockCount: Object.keys(workflowState.blocks || {}).length,
|
||||
fromDiffStore:
|
||||
!!diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0,
|
||||
})
|
||||
|
||||
logger.info('About to stringify workflow state', {
|
||||
workflowId,
|
||||
workflowStateKeys: Object.keys(workflowState),
|
||||
})
|
||||
|
||||
// Convert workflow state to JSON string
|
||||
let workflowJson: string
|
||||
try {
|
||||
workflowJson = JSON.stringify(workflowState, null, 2)
|
||||
logger.info('Successfully stringified workflow state', {
|
||||
workflowId,
|
||||
jsonLength: workflowJson.length,
|
||||
})
|
||||
} catch (stringifyError) {
|
||||
logger.error('Error stringifying workflow state', {
|
||||
workflowId,
|
||||
error: stringifyError,
|
||||
})
|
||||
options?.onStateChange?.('errored')
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to convert workflow to JSON: ${stringifyError instanceof Error ? stringifyError.message : 'Unknown error'}`,
|
||||
}
|
||||
}
|
||||
logger.info('About to notify server with workflow data', {
|
||||
workflowId,
|
||||
toolCallId: toolCall.id,
|
||||
dataLength: workflowJson.length,
|
||||
})
|
||||
|
||||
// Notify server of success with structured data containing userWorkflow
|
||||
const structuredData = JSON.stringify({
|
||||
userWorkflow: workflowJson,
|
||||
})
|
||||
|
||||
logger.info('Calling notify with structured data', {
|
||||
toolCallId: toolCall.id,
|
||||
structuredDataLength: structuredData.length,
|
||||
})
|
||||
|
||||
await this.notify(toolCall.id, 'success', structuredData)
|
||||
|
||||
logger.info('Successfully notified server of success', {
|
||||
toolCallId: toolCall.id,
|
||||
})
|
||||
|
||||
options?.onStateChange?.('success')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: workflowJson, // Return the same data that goes to Redis
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error in client tool execution:', {
|
||||
toolCallId: toolCall.id,
|
||||
error: error,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
try {
|
||||
// Notify server of error
|
||||
await this.notify(toolCall.id, 'errored', error.message || 'Failed to fetch workflow')
|
||||
logger.info('Successfully notified server of error', {
|
||||
toolCallId: toolCall.id,
|
||||
})
|
||||
} catch (notifyError) {
|
||||
logger.error('Failed to notify server of error:', {
|
||||
toolCallId: toolCall.id,
|
||||
notifyError: notifyError,
|
||||
})
|
||||
}
|
||||
|
||||
options?.onStateChange?.('errored')
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch workflow',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* It also provides metadata for server-side tools for display purposes
|
||||
*/
|
||||
|
||||
import { GetUserWorkflowTool } from '@/lib/copilot/tools/client-tools/get-user-workflow'
|
||||
import { RunWorkflowTool } from '@/lib/copilot/tools/client-tools/run-workflow'
|
||||
import { SERVER_TOOL_METADATA } from '@/lib/copilot/tools/server-tools/definitions'
|
||||
import type { Tool, ToolMetadata } from '@/lib/copilot/tools/types'
|
||||
@@ -112,6 +113,7 @@ export class ToolRegistry {
|
||||
private registerDefaultTools(): void {
|
||||
// Register actual client tool implementations
|
||||
this.register(new RunWorkflowTool())
|
||||
this.register(new GetUserWorkflowTool())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,11 +273,16 @@ async function applyOperationsToYaml(
|
||||
return yamlDump(workflowData)
|
||||
}
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
import { workflow as workflowTable } from '@/db/schema'
|
||||
import { BaseCopilotTool } from '../base'
|
||||
|
||||
interface EditWorkflowParams {
|
||||
operations: EditWorkflowOperation[]
|
||||
workflowId: string
|
||||
currentUserWorkflow?: string // Optional current workflow JSON - if not provided, will fetch from DB
|
||||
}
|
||||
|
||||
interface EditWorkflowResult {
|
||||
@@ -297,28 +302,110 @@ class EditWorkflowTool extends BaseCopilotTool<EditWorkflowParams, EditWorkflowR
|
||||
// Export the tool instance
|
||||
export const editWorkflowTool = new EditWorkflowTool()
|
||||
|
||||
/**
|
||||
* Get user workflow from database - backend function for edit workflow
|
||||
*/
|
||||
async function getUserWorkflow(workflowId: string): Promise<string> {
|
||||
logger.info('Fetching workflow from database', { workflowId })
|
||||
|
||||
// Fetch workflow from database
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
throw new Error(`Workflow ${workflowId} not found in database`)
|
||||
}
|
||||
|
||||
// Try to load from normalized tables first, fallback to JSON blob
|
||||
let workflowState: any = null
|
||||
const subBlockValues: Record<string, Record<string, any>> = {}
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (normalizedData) {
|
||||
workflowState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
}
|
||||
|
||||
// Extract subblock values from normalized data
|
||||
Object.entries(normalizedData.blocks).forEach(([blockId, block]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
|
||||
if ((subBlock as any).value !== undefined) {
|
||||
subBlockValues[blockId][subBlockId] = (subBlock as any).value
|
||||
}
|
||||
})
|
||||
})
|
||||
} else if (workflowRecord.state) {
|
||||
// Fallback to JSON blob
|
||||
const jsonState = workflowRecord.state as any
|
||||
workflowState = {
|
||||
blocks: jsonState.blocks || {},
|
||||
edges: jsonState.edges || [],
|
||||
loops: jsonState.loops || {},
|
||||
parallels: jsonState.parallels || {},
|
||||
}
|
||||
// For JSON blob, subblock values are embedded in the block state
|
||||
Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
|
||||
if ((subBlock as any).value !== undefined) {
|
||||
subBlockValues[blockId][subBlockId] = (subBlock as any).value
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (!workflowState || !workflowState.blocks) {
|
||||
throw new Error('Workflow state is empty or invalid')
|
||||
}
|
||||
|
||||
logger.info('Successfully fetched workflow from database', {
|
||||
workflowId,
|
||||
blockCount: Object.keys(workflowState.blocks).length,
|
||||
})
|
||||
|
||||
// Return the raw JSON workflow state
|
||||
return JSON.stringify(workflowState, null, 2)
|
||||
}
|
||||
|
||||
// Implementation function
|
||||
async function editWorkflow(params: EditWorkflowParams): Promise<EditWorkflowResult> {
|
||||
const { operations, workflowId } = params
|
||||
const { operations, workflowId, currentUserWorkflow } = params
|
||||
|
||||
logger.info('Processing targeted update request', {
|
||||
workflowId,
|
||||
operationCount: operations.length,
|
||||
hasCurrentUserWorkflow: !!currentUserWorkflow,
|
||||
})
|
||||
|
||||
// Get current workflow state as JSON
|
||||
const { getUserWorkflowTool } = await import('./get-user-workflow')
|
||||
// Get current workflow state - use provided currentUserWorkflow or fetch from DB
|
||||
let workflowStateJson: string
|
||||
|
||||
const getUserWorkflowResult = await getUserWorkflowTool.execute({
|
||||
workflowId: workflowId,
|
||||
includeMetadata: false,
|
||||
})
|
||||
|
||||
if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) {
|
||||
throw new Error('Failed to get current workflow state')
|
||||
if (currentUserWorkflow) {
|
||||
logger.info('Using provided currentUserWorkflow for edits', {
|
||||
workflowId,
|
||||
jsonLength: currentUserWorkflow.length,
|
||||
})
|
||||
workflowStateJson = currentUserWorkflow
|
||||
} else {
|
||||
logger.info('No currentUserWorkflow provided, fetching from database', {
|
||||
workflowId,
|
||||
})
|
||||
workflowStateJson = await getUserWorkflow(workflowId)
|
||||
}
|
||||
|
||||
const workflowStateJson = getUserWorkflowResult.data
|
||||
// Also get the DB version for diff calculation if we're using a different current workflow
|
||||
let dbWorkflowStateJson: string = workflowStateJson
|
||||
if (currentUserWorkflow) {
|
||||
logger.info('Fetching DB workflow for diff calculation', { workflowId })
|
||||
dbWorkflowStateJson = await getUserWorkflow(workflowId)
|
||||
}
|
||||
|
||||
logger.info('Retrieved current workflow state', {
|
||||
jsonLength: workflowStateJson.length,
|
||||
@@ -328,6 +415,20 @@ async function editWorkflow(params: EditWorkflowParams): Promise<EditWorkflowRes
|
||||
// Parse the JSON to get the workflow state object
|
||||
const workflowState = JSON.parse(workflowStateJson)
|
||||
|
||||
// Ensure workflow state has all required properties with proper defaults
|
||||
if (!workflowState.loops) {
|
||||
workflowState.loops = {}
|
||||
}
|
||||
if (!workflowState.parallels) {
|
||||
workflowState.parallels = {}
|
||||
}
|
||||
if (!workflowState.edges) {
|
||||
workflowState.edges = []
|
||||
}
|
||||
if (!workflowState.blocks) {
|
||||
workflowState.blocks = {}
|
||||
}
|
||||
|
||||
// Extract subblock values from the workflow state (same logic as get-user-workflow.ts)
|
||||
const subBlockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
|
||||
@@ -1,90 +1,96 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
import { workflow as workflowTable } from '@/db/schema'
|
||||
import { BaseCopilotTool } from '../base'
|
||||
|
||||
interface GetUserWorkflowParams {
|
||||
workflowId: string
|
||||
workflowId?: string
|
||||
includeMetadata?: boolean
|
||||
confirmationMessage?: string
|
||||
fullData?: any
|
||||
}
|
||||
|
||||
class GetUserWorkflowTool extends BaseCopilotTool<GetUserWorkflowParams, string> {
|
||||
readonly id = 'get_user_workflow'
|
||||
readonly displayName = 'Analyzing your workflow'
|
||||
readonly requiresInterrupt = true // This triggers automatic Redis polling
|
||||
|
||||
protected async executeImpl(params: GetUserWorkflowParams): Promise<string> {
|
||||
return getUserWorkflow(params)
|
||||
const logger = createLogger('GetUserWorkflow')
|
||||
|
||||
logger.info('Server tool received params', {
|
||||
hasFullData: !!params.fullData,
|
||||
hasConfirmationMessage: !!params.confirmationMessage,
|
||||
fullDataType: typeof params.fullData,
|
||||
fullDataKeys: params.fullData ? Object.keys(params.fullData) : null,
|
||||
confirmationMessageLength: params.confirmationMessage?.length || 0,
|
||||
})
|
||||
|
||||
// Extract the workflow data from fullData or confirmationMessage
|
||||
let workflowData: string | null = null
|
||||
|
||||
if (params.fullData?.userWorkflow) {
|
||||
// New format: fullData contains structured data with userWorkflow field
|
||||
workflowData = params.fullData.userWorkflow
|
||||
logger.info('Using workflow data from fullData.userWorkflow', {
|
||||
dataLength: workflowData?.length || 0,
|
||||
})
|
||||
} else if (params.confirmationMessage) {
|
||||
// The confirmationMessage might contain the structured JSON data
|
||||
logger.info('Attempting to parse confirmationMessage as structured data', {
|
||||
messageLength: params.confirmationMessage.length,
|
||||
messagePreview: params.confirmationMessage.substring(0, 100),
|
||||
})
|
||||
|
||||
try {
|
||||
// Try to parse the confirmation message as structured data
|
||||
const parsedMessage = JSON.parse(params.confirmationMessage)
|
||||
if (parsedMessage?.userWorkflow) {
|
||||
workflowData = parsedMessage.userWorkflow
|
||||
logger.info('Successfully extracted userWorkflow from confirmationMessage', {
|
||||
dataLength: workflowData?.length || 0,
|
||||
})
|
||||
} else {
|
||||
// Fallback: treat the entire message as workflow data
|
||||
workflowData = params.confirmationMessage
|
||||
logger.info('Using confirmationMessage directly as workflow data', {
|
||||
dataLength: workflowData.length,
|
||||
})
|
||||
}
|
||||
} catch (parseError) {
|
||||
// If parsing fails, use the message directly
|
||||
workflowData = params.confirmationMessage
|
||||
logger.info('Failed to parse confirmationMessage, using directly', {
|
||||
dataLength: workflowData.length,
|
||||
parseError: parseError instanceof Error ? parseError.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw new Error('No workflow data received from client tool')
|
||||
}
|
||||
|
||||
if (!workflowData) {
|
||||
throw new Error('No workflow data available')
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the workflow data to validate it's valid JSON
|
||||
const workflowState = JSON.parse(workflowData)
|
||||
|
||||
if (!workflowState || !workflowState.blocks) {
|
||||
throw new Error('Invalid workflow state received from client tool')
|
||||
}
|
||||
|
||||
logger.info('Successfully parsed and validated workflow data', {
|
||||
blockCount: Object.keys(workflowState.blocks).length,
|
||||
})
|
||||
|
||||
// Return the workflow data as properly formatted JSON string
|
||||
return JSON.stringify(workflowState, null, 2)
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse workflow data from client tool', { error })
|
||||
throw new Error('Invalid workflow data format received from client tool')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export the tool instance
|
||||
export const getUserWorkflowTool = new GetUserWorkflowTool()
|
||||
|
||||
// Implementation function
|
||||
async function getUserWorkflow(params: GetUserWorkflowParams): Promise<string> {
|
||||
const logger = createLogger('GetUserWorkflow')
|
||||
const { workflowId, includeMetadata = false } = params
|
||||
|
||||
logger.info('Fetching user workflow', { workflowId })
|
||||
|
||||
// Fetch workflow from database
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
throw new Error(`Workflow ${workflowId} not found`)
|
||||
}
|
||||
|
||||
// Try to load from normalized tables first, fallback to JSON blob
|
||||
let workflowState: any = null
|
||||
const subBlockValues: Record<string, Record<string, any>> = {}
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (normalizedData) {
|
||||
workflowState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
}
|
||||
|
||||
// Extract subblock values from normalized data
|
||||
Object.entries(normalizedData.blocks).forEach(([blockId, block]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
|
||||
if ((subBlock as any).value !== undefined) {
|
||||
subBlockValues[blockId][subBlockId] = (subBlock as any).value
|
||||
}
|
||||
})
|
||||
})
|
||||
} else if (workflowRecord.state) {
|
||||
// Fallback to JSON blob
|
||||
workflowState = workflowRecord.state as any
|
||||
// For JSON blob, subblock values are embedded in the block state
|
||||
Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
|
||||
if ((subBlock as any).value !== undefined) {
|
||||
subBlockValues[blockId][subBlockId] = (subBlock as any).value
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (!workflowState || !workflowState.blocks) {
|
||||
throw new Error('Workflow state is empty or invalid')
|
||||
}
|
||||
|
||||
logger.info('Successfully fetched user workflow as JSON', {
|
||||
workflowId,
|
||||
blockCount: Object.keys(workflowState.blocks).length,
|
||||
})
|
||||
|
||||
// Return the raw JSON workflow state
|
||||
return JSON.stringify(workflowState, null, 2)
|
||||
}
|
||||
|
||||
@@ -531,6 +531,43 @@ function createToolCall(id: string, name: string, input: any = {}): any {
|
||||
|
||||
setToolCallState(toolCall, initialState, { preserveTerminalStates: false })
|
||||
|
||||
// Auto-execute client tools that don't require interrupt
|
||||
if (!requiresInterrupt && toolRegistry.getTool(name)) {
|
||||
logger.info('Auto-executing client tool:', name, toolCall.id)
|
||||
// Execute client tool asynchronously
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const tool = toolRegistry.getTool(name)
|
||||
if (tool && toolCall.state === 'executing') {
|
||||
await tool.execute(toolCall as any, {
|
||||
onStateChange: (state: any) => {
|
||||
// Update the tool call state in the store
|
||||
const currentState = useCopilotStore.getState()
|
||||
const updatedMessages = currentState.messages.map((msg) => ({
|
||||
...msg,
|
||||
toolCalls: msg.toolCalls?.map((tc) =>
|
||||
tc.id === toolCall.id ? { ...tc, state } : tc
|
||||
),
|
||||
contentBlocks: msg.contentBlocks?.map((block) =>
|
||||
block.type === 'tool_call' && block.toolCall?.id === toolCall.id
|
||||
? { ...block, toolCall: { ...block.toolCall, state } }
|
||||
: block
|
||||
),
|
||||
}))
|
||||
|
||||
useCopilotStore.setState({ messages: updatedMessages })
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error auto-executing client tool:', name, toolCall.id, error)
|
||||
setToolCallState(toolCall, 'errored', {
|
||||
error: error instanceof Error ? error.message : 'Auto-execution failed',
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return toolCall
|
||||
}
|
||||
|
||||
@@ -2747,17 +2784,8 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
hasDiffWorkflow: !!diffStoreBefore.diffWorkflow,
|
||||
})
|
||||
|
||||
// Determine if we should clear or merge based on tool type and message context
|
||||
const { messages } = get()
|
||||
const currentMessage = messages[messages.length - 1]
|
||||
const messageHasExistingEdits =
|
||||
currentMessage?.toolCalls?.some(
|
||||
(tc) =>
|
||||
(tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW ||
|
||||
tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) &&
|
||||
tc.state !== 'executing'
|
||||
) || false
|
||||
|
||||
// Determine diff merge strategy based on tool type and existing edits
|
||||
const messageHasExistingEdits = !!diffStoreBefore.diffWorkflow
|
||||
const shouldClearDiff =
|
||||
toolName === COPILOT_TOOL_IDS.BUILD_WORKFLOW || // build_workflow always clears
|
||||
(toolName === COPILOT_TOOL_IDS.EDIT_WORKFLOW && !messageHasExistingEdits) // first edit_workflow in message clears
|
||||
@@ -2787,15 +2815,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
hasDiffWorkflow: !!diffStoreBefore.diffWorkflow,
|
||||
})
|
||||
|
||||
if (shouldClearDiff || !diffStoreBefore.diffWorkflow) {
|
||||
// Use setProposedChanges which will create a new diff
|
||||
// Pass undefined to let sim-agent generate the diff analysis
|
||||
await diffStore.setProposedChanges(yamlContent, undefined)
|
||||
} else {
|
||||
// Use mergeProposedChanges which will merge into existing diff
|
||||
// Pass undefined to let sim-agent generate the diff analysis
|
||||
await diffStore.mergeProposedChanges(yamlContent, undefined)
|
||||
}
|
||||
// Always use setProposedChanges to ensure the diff view fully overwrites with new changes
|
||||
// This provides better UX as users expect to see the latest changes, not merged/cumulative changes
|
||||
await diffStore.setProposedChanges(yamlContent, undefined)
|
||||
|
||||
// Check diff store state after update
|
||||
const diffStoreAfter = useWorkflowDiffStore.getState()
|
||||
|
||||
@@ -114,6 +114,9 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
// PERFORMANCE OPTIMIZATION: Immediate state update to prevent UI flicker
|
||||
batchedUpdate({ isDiffReady: false })
|
||||
|
||||
// Clear any existing diff state to ensure a fresh start
|
||||
diffEngine.clearDiff()
|
||||
|
||||
const result = await diffEngine.createDiffFromYaml(yamlContent, diffAnalysis)
|
||||
|
||||
if (result.success && result.diff) {
|
||||
|
||||
Reference in New Issue
Block a user