From 25a11bfafc91df9dee53e79c51d6f73408e40a66 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Tue, 2 Sep 2025 21:36:52 +0800 Subject: [PATCH] Export DSL from history (#24939) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/app.py | 7 +- api/services/app_dsl_service.py | 10 ++- api/services/workflow_service.py | 8 +- .../services/test_app_dsl_service.py | 82 ++++++++++++++++++- .../hooks/use-workflow-interactions.ts | 3 +- .../context-menu/use-context-menu.ts | 4 + .../panel/version-history-panel/index.tsx | 8 +- web/app/components/workflow/types.ts | 1 + web/service/apps.ts | 9 +- 9 files changed, 119 insertions(+), 13 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index a6eb86122..10753d2f9 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -237,9 +237,14 @@ class AppExportApi(Resource): # Add include_secret params parser = reqparse.RequestParser() parser.add_argument("include_secret", type=inputs.boolean, default=False, location="args") + parser.add_argument("workflow_id", type=str, location="args") args = parser.parse_args() - return {"data": AppDslService.export_dsl(app_model=app_model, include_secret=args["include_secret"])} + return { + "data": AppDslService.export_dsl( + app_model=app_model, include_secret=args["include_secret"], workflow_id=args.get("workflow_id") + ) + } class AppNameApi(Resource): diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 18c72ebde..2663cb380 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -532,7 +532,7 @@ class AppDslService: return app @classmethod - def export_dsl(cls, app_model: App, include_secret: bool = False) -> str: + def export_dsl(cls, app_model: App, include_secret: bool = False, workflow_id: Optional[str] = None) -> str: """ Export app :param app_model: App instance @@ -556,7 +556,7 @@ class AppDslService: if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: cls._append_workflow_export_data( - export_data=export_data, app_model=app_model, include_secret=include_secret + export_data=export_data, app_model=app_model, include_secret=include_secret, workflow_id=workflow_id ) else: cls._append_model_config_export_data(export_data, app_model) @@ -564,14 +564,16 @@ class AppDslService: return yaml.dump(export_data, allow_unicode=True) # type: ignore @classmethod - def _append_workflow_export_data(cls, *, export_data: dict, app_model: App, include_secret: bool) -> None: + def _append_workflow_export_data( + cls, *, export_data: dict, app_model: App, include_secret: bool, workflow_id: Optional[str] = None + ) -> None: """ Append workflow export data :param export_data: export data :param app_model: App instance """ workflow_service = WorkflowService() - workflow = workflow_service.get_draft_workflow(app_model) + workflow = workflow_service.get_draft_workflow(app_model, workflow_id) if not workflow: raise ValueError("Missing draft workflow configuration, please check.") diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 3a6837978..3f54f6624 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -96,10 +96,12 @@ class WorkflowService: ) return db.session.execute(stmt).scalar_one() - def get_draft_workflow(self, app_model: App) -> Optional[Workflow]: + def get_draft_workflow(self, app_model: App, workflow_id: Optional[str] = None) -> Optional[Workflow]: """ Get draft workflow """ + if workflow_id: + return self.get_published_workflow_by_id(app_model, workflow_id) # fetch draft workflow by app_model workflow = ( db.session.query(Workflow) @@ -115,7 +117,9 @@ class WorkflowService: return workflow def get_published_workflow_by_id(self, app_model: App, workflow_id: str) -> Optional[Workflow]: - # fetch published workflow by workflow_id + """ + fetch published workflow by workflow_id + """ workflow = ( db.session.query(Workflow) .where( diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index d83983d0f..119f92d77 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -322,7 +322,87 @@ class TestAppDslService: # Verify workflow service was called mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( - app + app, None + ) + + def test_export_dsl_with_workflow_id_success(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test successful DSL export with specific workflow ID. + """ + fake = Faker() + app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + + # Update app to workflow mode + app.mode = "workflow" + db_session_with_containers.commit() + + # Mock workflow service to return a workflow when specific workflow_id is provided + mock_workflow = MagicMock() + mock_workflow.to_dict.return_value = { + "graph": {"nodes": [{"id": "start", "type": "start", "data": {"type": "start"}}], "edges": []}, + "features": {}, + "environment_variables": [], + "conversation_variables": [], + } + + # Mock the get_draft_workflow method to return different workflows based on workflow_id + def mock_get_draft_workflow(app_model, workflow_id=None): + if workflow_id == "specific-workflow-id": + return mock_workflow + return None + + mock_external_service_dependencies[ + "workflow_service" + ].return_value.get_draft_workflow.side_effect = mock_get_draft_workflow + + # Export DSL with specific workflow ID + exported_dsl = AppDslService.export_dsl(app, include_secret=False, workflow_id="specific-workflow-id") + + # Parse exported YAML + exported_data = yaml.safe_load(exported_dsl) + + # Verify exported data structure + assert exported_data["kind"] == "app" + assert exported_data["app"]["name"] == app.name + assert exported_data["app"]["mode"] == "workflow" + + # Verify workflow was exported + assert "workflow" in exported_data + assert "graph" in exported_data["workflow"] + assert "nodes" in exported_data["workflow"]["graph"] + + # Verify dependencies were exported + assert "dependencies" in exported_data + assert isinstance(exported_data["dependencies"], list) + + # Verify workflow service was called with specific workflow ID + mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( + app, "specific-workflow-id" + ) + + def test_export_dsl_with_invalid_workflow_id_raises_error( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test that export_dsl raises error when invalid workflow ID is provided. + """ + fake = Faker() + app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) + + # Update app to workflow mode + app.mode = "workflow" + db_session_with_containers.commit() + + # Mock workflow service to return None when invalid workflow ID is provided + mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.return_value = None + + # Export DSL with invalid workflow ID should raise ValueError + with pytest.raises(ValueError, match="Missing draft workflow configuration, please check."): + AppDslService.export_dsl(app, include_secret=False, workflow_id="invalid-workflow-id") + + # Verify workflow service was called with the invalid workflow ID + mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with( + app, "invalid-workflow-id" ) def test_check_dependencies_success(self, db_session_with_containers, mock_external_service_dependencies): diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index d8653a594..009e4d878 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -346,7 +346,7 @@ export const useDSL = () => { const appDetail = useAppStore(s => s.appDetail) - const handleExportDSL = useCallback(async (include = false) => { + const handleExportDSL = useCallback(async (include = false, workflowId?: string) => { if (!appDetail) return @@ -358,6 +358,7 @@ export const useDSL = () => { await doSyncWorkflowDraft() const { data } = await exportAppConfig({ appID: appDetail.id, + workflowID: workflowId, include, }) const a = document.createElement('a') diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts b/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts index 62043713e..c56d286f8 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts @@ -29,6 +29,10 @@ const useContextMenu = (props: ContextMenuProps) => { key: VersionHistoryContextMenuOptions.edit, name: t('workflow.versionHistory.nameThisVersion'), }, + { + key: VersionHistoryContextMenuOptions.exportDSL, + name: t('app.export'), + }, { key: VersionHistoryContextMenuOptions.copyId, name: t('workflow.versionHistory.copyId'), diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 70acca759..5a1bfe01e 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react' import copy from 'copy-to-clipboard' -import { useNodesSyncDraft, useWorkflowRun } from '../../hooks' +import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks' import { useStore, useWorkflowStore } from '../../store' import { VersionHistoryContextMenuOptions, WorkflowVersionFilterOptions } from '../../types' import VersionHistoryItem from './version-history-item' @@ -33,6 +33,7 @@ const VersionHistoryPanel = () => { const workflowStore = useWorkflowStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() + const { handleExportDSL } = useDSL() const appDetail = useAppStore.getState().appDetail const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) const currentVersion = useStore(s => s.currentVersion) @@ -107,8 +108,11 @@ const VersionHistoryPanel = () => { message: t('workflow.versionHistory.action.copyIdSuccess'), }) break + case VersionHistoryContextMenuOptions.exportDSL: + handleExportDSL(false, item.id) + break } - }, [t]) + }, [t, handleExportDSL]) const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => { switch (operation) { diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 5cad042cf..30c00c7ef 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -452,6 +452,7 @@ export enum VersionHistoryContextMenuOptions { restore = 'restore', edit = 'edit', delete = 'delete', + exportDSL = 'exportDSL', copyId = 'copyId', } diff --git a/web/service/apps.ts b/web/service/apps.ts index 1d7b0bccd..5602f7579 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -35,8 +35,13 @@ export const copyApp: Fetcher(`apps/${appID}/copy`, { body: { name, icon_type, icon, icon_background, mode, description } }) } -export const exportAppConfig: Fetcher<{ data: string }, { appID: string; include?: boolean }> = ({ appID, include = false }) => { - return get<{ data: string }>(`apps/${appID}/export?include_secret=${include}`) +export const exportAppConfig: Fetcher<{ data: string }, { appID: string; include?: boolean; workflowID?: string }> = ({ appID, include = false, workflowID }) => { + const params = new URLSearchParams({ + include_secret: include.toString(), + }) + if (workflowID) + params.append('workflow_id', workflowID) + return get<{ data: string }>(`apps/${appID}/export?${params.toString()}`) } // TODO: delete