From 6faca9c558f83dc512c556c1be39d0dd89eddbc0 Mon Sep 17 00:00:00 2001 From: Stanislav Novosad Date: Wed, 28 Jan 2026 16:30:28 -0700 Subject: [PATCH] Copilot: fix review button by conversion YAML server side (#4573) --- .../workflows/copilot/workflowCopilotTypes.ts | 14 +- .../src/routes/workflows/editor/Workspace.tsx | 208 ++++++++++-------- skyvern/forge/sdk/routes/workflow_copilot.py | 30 +++ skyvern/forge/sdk/schemas/workflow_copilot.py | 9 + 4 files changed, 169 insertions(+), 92 deletions(-) diff --git a/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts b/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts index e4fe3b90..75987f91 100644 --- a/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts +++ b/skyvern-frontend/src/routes/workflows/copilot/workflowCopilotTypes.ts @@ -1,4 +1,7 @@ -import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes"; +import { + WorkflowApiResponse, + WorkflowDefinition, +} from "@/routes/workflows/types/workflowTypes"; export type WorkflowCopilotChatSender = "user" | "ai"; @@ -70,3 +73,12 @@ export interface WorkflowCopilotStreamErrorUpdate { type: "error"; error: string; } + +export interface WorkflowYAMLConversionRequest { + workflow_definition_yaml: string; + workflow_id: string; +} + +export interface WorkflowYAMLConversionResponse { + workflow_definition: WorkflowDefinition; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 27d76b3d..147e579f 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -8,6 +8,7 @@ import { MutableRefObject, } from "react"; import { nanoid } from "nanoid"; +import { stringify as convertToYAML } from "yaml"; import { CheckIcon, ChevronRightIcon, @@ -94,11 +95,7 @@ import { import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel"; import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery"; -import { - Parameter, - WorkflowBlock, - WorkflowSettings, -} from "../types/workflowTypes"; +import { WorkflowSettings } from "../types/workflowTypes"; import { ProxyLocation } from "@/api/types"; import { nodeAdderNode, @@ -112,6 +109,7 @@ import { constructCacheKeyValue, getInitialParameters } from "./utils"; import { WorkflowCopilotChat } from "../copilot/WorkflowCopilotChat"; import { WorkflowCopilotButton } from "../copilot/WorkflowCopilotButton"; import type { CopilotReviewStatus } from "./panels/WorkflowComparisonPanel"; +import type { WorkflowYAMLConversionResponse } from "../copilot/workflowCopilotTypes"; import "./workspace-styles.css"; const Constants = { @@ -1743,105 +1741,133 @@ function Workspace({ onClose={() => setIsCopilotOpen(false)} onMessageCountChange={setCopilotMessageCount} buttonRef={copilotButtonRef} - onReviewWorkflow={(pendingWorkflow, clearPending) => { + onReviewWorkflow={async (pendingWorkflow, clearPending) => { const saveData = workflowChangesStore.getSaveData?.(); if (!saveData) return; - // Construct fake WorkflowVersion for current state - const currentVersion: WorkflowVersion = { - workflow_id: saveData.workflow.workflow_id, - organization_id: "", - is_saved_task: saveData.workflow.is_saved_task ?? false, - is_template: false, - title: "Current", - workflow_permanent_id: saveData.workflow.workflow_permanent_id, - version: saveData.workflow.version ?? 0, - description: saveData.workflow.description ?? "", - workflow_definition: { - parameters: saveData.parameters as unknown as Parameter[], - blocks: saveData.blocks as unknown as WorkflowBlock[], - finally_block_label: saveData.settings.finallyBlockLabel, - }, - proxy_location: saveData.settings.proxyLocation, - webhook_callback_url: saveData.settings.webhookCallbackUrl, - extra_http_headers: saveData.settings.extraHttpHeaders - ? JSON.parse(saveData.settings.extraHttpHeaders) - : null, - persist_browser_session: saveData.settings.persistBrowserSession, - model: saveData.settings.model, - totp_verification_url: saveData.workflow.totp_verification_url, - totp_identifier: null, - max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls, - status: saveData.workflow.status, - created_at: new Date().toISOString(), - modified_at: new Date().toISOString(), - deleted_at: null, - run_with: saveData.settings.runWith, - cache_key: saveData.settings.scriptCacheKey, - ai_fallback: saveData.settings.aiFallback, - run_sequentially: saveData.settings.runSequentially, - sequential_key: saveData.settings.sequentialKey, - folder_id: null, - import_error: null, - }; + try { + // Create YAML from current workflow definition only + const workflowDefinitionYaml = convertToYAML({ + version: saveData.workflowDefinitionVersion, + parameters: saveData.parameters, + blocks: saveData.blocks, + finally_block_label: + saveData.settings.finallyBlockLabel ?? undefined, + }); - // Construct fake WorkflowVersion for pending copilot suggestion - const pendingVersion: WorkflowVersion = { - ...pendingWorkflow, - title: "Copilot Suggestion", - }; + // Convert current workflow definition YAML to blocks + const client = await getClient(credentialGetter, "sans-api-v1"); - // Handle copilot review close with status - const handleCopilotReviewClose = (status: CopilotReviewStatus) => { - if (status === "approve") { - try { - applyWorkflowUpdate(pendingWorkflow); - } catch (error) { - console.error( - "Failed to apply copilot workflow", - error, - pendingWorkflow, - ); - toast({ - title: "Update failed", - description: - "Failed to apply workflow update. Please try again.", - variant: "destructive", - }); + const currentConversionResponse = + await client.post( + "/workflow/copilot/convert-yaml-to-blocks", + { + workflow_definition_yaml: workflowDefinitionYaml, + workflow_id: saveData.workflow.workflow_id, + }, + ); + + // Construct WorkflowVersion for current state with converted blocks + const currentVersion: WorkflowVersion = { + workflow_id: saveData.workflow.workflow_id, + organization_id: "", + is_saved_task: saveData.workflow.is_saved_task ?? false, + is_template: false, + title: "Current", + workflow_permanent_id: saveData.workflow.workflow_permanent_id, + version: saveData.workflow.version ?? 0, + description: saveData.workflow.description ?? "", + workflow_definition: + currentConversionResponse.data.workflow_definition, + proxy_location: saveData.settings.proxyLocation, + webhook_callback_url: saveData.settings.webhookCallbackUrl, + extra_http_headers: saveData.settings.extraHttpHeaders + ? JSON.parse(saveData.settings.extraHttpHeaders) + : null, + persist_browser_session: saveData.settings.persistBrowserSession, + model: saveData.settings.model, + totp_verification_url: saveData.workflow.totp_verification_url, + totp_identifier: null, + max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls, + status: saveData.workflow.status, + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), + deleted_at: null, + run_with: saveData.settings.runWith, + cache_key: saveData.settings.scriptCacheKey, + ai_fallback: saveData.settings.aiFallback, + run_sequentially: saveData.settings.runSequentially, + sequential_key: saveData.settings.sequentialKey, + folder_id: null, + import_error: null, + }; + + // Construct fake WorkflowVersion for pending copilot suggestion + const pendingVersion: WorkflowVersion = { + ...pendingWorkflow, + title: "Copilot Suggestion", + }; + + // Handle copilot review close with status + const handleCopilotReviewClose = (status: CopilotReviewStatus) => { + if (status === "approve") { + try { + applyWorkflowUpdate(pendingWorkflow); + } catch (error) { + console.error( + "Failed to apply copilot workflow", + error, + pendingWorkflow, + ); + toast({ + title: "Update failed", + description: + "Failed to apply workflow update. Please try again.", + variant: "destructive", + }); + } } - } - // Close the panel and reopen copilot chat + // Close the panel and reopen copilot chat + setWorkflowPanelState({ + active: false, + content: "history", + data: { + showComparison: false, + version1: undefined, + version2: undefined, + }, + }); + setIsCopilotOpen(true); + + // Clear pending for approve and reject, but not for close + if (status !== "close") { + clearPending(); + } + }; + + // Hide chat and show comparison + setIsCopilotOpen(false); setWorkflowPanelState({ active: false, content: "history", data: { - showComparison: false, - version1: undefined, - version2: undefined, + version1: currentVersion, + version2: pendingVersion, + showComparison: true, + mode: "copilot", + onCopilotReviewClose: handleCopilotReviewClose, }, }); - setIsCopilotOpen(true); - - // Clear pending for approve and reject, but not for close - if (status !== "close") { - clearPending(); - } - }; - - // Hide chat and show comparison - setIsCopilotOpen(false); - setWorkflowPanelState({ - active: false, - content: "history", - data: { - version1: currentVersion, - version2: pendingVersion, - showComparison: true, - mode: "copilot", - onCopilotReviewClose: handleCopilotReviewClose, - }, - }); + } catch (error) { + console.error("Failed to prepare workflow comparison", error); + toast({ + title: "Comparison failed", + description: + "Failed to prepare workflow for comparison. Please try again.", + variant: "destructive", + }); + } }} onWorkflowUpdate={(workflowData) => { try { diff --git a/skyvern/forge/sdk/routes/workflow_copilot.py b/skyvern/forge/sdk/routes/workflow_copilot.py index 850cdc06..0f6caca7 100644 --- a/skyvern/forge/sdk/routes/workflow_copilot.py +++ b/skyvern/forge/sdk/routes/workflow_copilot.py @@ -31,6 +31,8 @@ from skyvern.forge.sdk.schemas.workflow_copilot import ( WorkflowCopilotStreamErrorUpdate, WorkflowCopilotStreamMessageType, WorkflowCopilotStreamResponseUpdate, + WorkflowYAMLConversionRequest, + WorkflowYAMLConversionResponse, ) from skyvern.forge.sdk.services import org_auth_service from skyvern.forge.sdk.workflow.exceptions import BaseWorkflowHTTPException @@ -40,6 +42,7 @@ from skyvern.forge.sdk.workflow.workflow_definition_converter import convert_wor from skyvern.schemas.workflows import ( LoginBlockYAML, WorkflowCreateYAMLRequest, + WorkflowDefinitionYAML, ) WORKFLOW_KNOWLEDGE_BASE_PATH = Path("skyvern/forge/prompts/skyvern/workflow_knowledge_base.txt") @@ -566,3 +569,30 @@ def convert_to_history_messages( ) for message in messages ] + + +@base_router.post("/workflow/copilot/convert-yaml-to-blocks", include_in_schema=False) +async def workflow_copilot_convert_yaml_to_blocks( + request: WorkflowYAMLConversionRequest, + organization: Organization = Depends(org_auth_service.get_current_org), +) -> WorkflowYAMLConversionResponse: + """ + Convert workflow definition YAML to blocks format for comparison view. + This endpoint is used by the frontend to convert YAML to the proper blocks structure + that the comparison panel expects. + """ + try: + parsed_yaml = yaml.safe_load(request.workflow_definition_yaml) + workflow_definition_yaml = WorkflowDefinitionYAML.model_validate(parsed_yaml) + + workflow_definition = convert_workflow_definition( + workflow_definition_yaml=workflow_definition_yaml, + workflow_id=request.workflow_id, + ) + + return WorkflowYAMLConversionResponse(workflow_definition=workflow_definition.model_dump(mode="json")) + except (yaml.YAMLError, ValidationError, BaseWorkflowHTTPException) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Failed to convert workflow YAML: {str(e)}", + ) diff --git a/skyvern/forge/sdk/schemas/workflow_copilot.py b/skyvern/forge/sdk/schemas/workflow_copilot.py index fdf87e29..4c304cf5 100644 --- a/skyvern/forge/sdk/schemas/workflow_copilot.py +++ b/skyvern/forge/sdk/schemas/workflow_copilot.py @@ -87,3 +87,12 @@ class WorkflowCopilotStreamResponseUpdate(BaseModel): class WorkflowCopilotStreamErrorUpdate(BaseModel): type: WorkflowCopilotStreamMessageType = Field(WorkflowCopilotStreamMessageType.ERROR, description="Message type") error: str = Field(..., description="Error message") + + +class WorkflowYAMLConversionRequest(BaseModel): + workflow_definition_yaml: str = Field(..., description="Workflow definition YAML to convert to blocks") + workflow_id: str = Field(..., description="Workflow ID") + + +class WorkflowYAMLConversionResponse(BaseModel): + workflow_definition: dict = Field(..., description="Converted workflow definition with blocks")