From c1e2c2e1eef1adcc4e030cece9f2e4a71ea46927 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Mon, 11 May 2026 13:44:13 +0800 Subject: [PATCH] fix(workflow): address migration CI failures Expose workflow lookup for Service API compatibility, tighten workflow template typing, and add focused command coverage for the migration PR. --- .../service_api/app/legacy_system_files.py | 2 +- api/services/app_generate_service.py | 6 ++- ...test_migrate_legacy_sys_files_workflows.py | 40 ++++++++++++++++++ .../unit_tests/commands/test_workspace.py | 33 +++++++++++++++ .../app/test_legacy_system_files.py | 2 +- .../hooks/use-workflow-template.ts | 42 +++++++++++-------- 6 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 api/tests/unit_tests/commands/test_workspace.py diff --git a/api/controllers/service_api/app/legacy_system_files.py b/api/controllers/service_api/app/legacy_system_files.py index e16ee1e2ab..aac5754e4a 100644 --- a/api/controllers/service_api/app/legacy_system_files.py +++ b/api/controllers/service_api/app/legacy_system_files.py @@ -23,7 +23,7 @@ def normalize_legacy_system_file_args_for_service_api( if not _has_legacy_file_arg(args_with_hidden_system): return args, None - workflow = AppGenerateService._get_workflow(app_model, InvokeFrom.SERVICE_API, workflow_id) + workflow = AppGenerateService.get_workflow(app_model, InvokeFrom.SERVICE_API, workflow_id) return normalize_legacy_sys_files_args(graph=workflow.graph_dict, args=args_with_hidden_system) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 2b054d0c7d..8f6bd619cf 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -399,7 +399,7 @@ class AppGenerateService: ) @classmethod - def _get_workflow(cls, app_model: App, invoke_from: InvokeFrom, workflow_id: str | None = None) -> Workflow: + def get_workflow(cls, app_model: App, invoke_from: InvokeFrom, workflow_id: str | None = None) -> Workflow: """ Get workflow :param app_model: app model @@ -435,6 +435,10 @@ class AppGenerateService: return workflow + @classmethod + def _get_workflow(cls, app_model: App, invoke_from: InvokeFrom, workflow_id: str | None = None) -> Workflow: + return cls.get_workflow(app_model, invoke_from, workflow_id) + @classmethod def get_response_generator( cls, diff --git a/api/tests/unit_tests/commands/test_migrate_legacy_sys_files_workflows.py b/api/tests/unit_tests/commands/test_migrate_legacy_sys_files_workflows.py index d53e0011ae..c14545910b 100644 --- a/api/tests/unit_tests/commands/test_migrate_legacy_sys_files_workflows.py +++ b/api/tests/unit_tests/commands/test_migrate_legacy_sys_files_workflows.py @@ -55,6 +55,18 @@ def test_migrate_legacy_sys_files_workflows_rejects_non_positive_batch_size(): ) +def test_migrate_legacy_sys_files_workflows_rejects_non_positive_limit(): + with pytest.raises(click.UsageError, match="limit"): + migrate_legacy_sys_files_workflows.callback( + batch_size=100, + limit=0, + start_after_id=None, + tenant_id=None, + app_id=None, + dry_run=False, + ) + + def test_build_legacy_sys_files_workflow_query_uses_keyset_pagination(): stmt = workflow_migration_commands._build_legacy_sys_files_workflow_query( start_after_id="workflow-1", @@ -96,3 +108,31 @@ def test_migrate_legacy_sys_files_workflow_batch_dry_run_rolls_back(): assert stats.last_id == "workflow-2" session.rollback.assert_called_once() session.commit.assert_not_called() + + +def test_migrate_legacy_sys_files_workflow_batch_commits_and_counts_failures(caplog): + migrated_workflow = MagicMock() + migrated_workflow.id = "workflow-1" + migrated_workflow.migrate_legacy_sys_files_graph_in_place.return_value = True + failing_workflow = MagicMock() + failing_workflow.id = "workflow-2" + failing_workflow.migrate_legacy_sys_files_graph_in_place.side_effect = RuntimeError("boom") + session = MagicMock() + session.scalars.return_value.all.return_value = [migrated_workflow, failing_workflow] + + stats = workflow_migration_commands._migrate_legacy_sys_files_workflow_batch( + session=session, + start_after_id=None, + batch_size=200, + tenant_id=None, + app_id=None, + dry_run=False, + ) + + assert stats.scanned == 2 + assert stats.migrated == 1 + assert stats.failed == 1 + assert stats.last_id == "workflow-2" + assert "Failed to migrate legacy" in caplog.text + session.commit.assert_called_once() + session.rollback.assert_not_called() diff --git a/api/tests/unit_tests/commands/test_workspace.py b/api/tests/unit_tests/commands/test_workspace.py new file mode 100644 index 0000000000..e7dbf6204e --- /dev/null +++ b/api/tests/unit_tests/commands/test_workspace.py @@ -0,0 +1,33 @@ +from unittest.mock import MagicMock + +from commands import reset_encrypt_key_pair +from commands import workspace as workspace_commands + + +def test_reset_encrypt_key_pair_skips_non_self_hosted(monkeypatch, capsys): + monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "CLOUD") + + reset_encrypt_key_pair.callback() + + captured = capsys.readouterr() + assert "only for SELF_HOSTED" in captured.out + + +def test_reset_encrypt_key_pair_rotates_keys_and_removes_custom_provider_data(monkeypatch, capsys): + monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(workspace_commands, "generate_key_pair", lambda tenant_id: f"public-key-{tenant_id}") + tenant = MagicMock() + tenant.id = "tenant-1" + session = MagicMock() + session.scalars.return_value.all.return_value = [tenant] + session_manager = MagicMock() + session_manager.begin.return_value.__enter__.return_value = session + monkeypatch.setattr(workspace_commands, "sessionmaker", lambda *args, **kwargs: session_manager) + monkeypatch.setattr(workspace_commands, "db", MagicMock(engine=object())) + + reset_encrypt_key_pair.callback() + + assert tenant.encrypt_public_key == "public-key-tenant-1" + assert session.execute.call_count == 2 + captured = capsys.readouterr() + assert "tenant-1 has been reset" in captured.out diff --git a/api/tests/unit_tests/controllers/service_api/app/test_legacy_system_files.py b/api/tests/unit_tests/controllers/service_api/app/test_legacy_system_files.py index a2716d6439..8ae0e22bf9 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_legacy_system_files.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_legacy_system_files.py @@ -22,7 +22,7 @@ def _legacy_file_graph() -> dict: def test_hidden_service_api_file_payload_maps_to_generated_start_input(mocker): workflow = MagicMock() workflow.graph_dict = _legacy_file_graph() - get_workflow = mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) + get_workflow = mocker.patch.object(AppGenerateService, "get_workflow", return_value=workflow) app_model = MagicMock() files = [{"transfer_method": "remote_url", "url": "https://example.com/a.png"}] diff --git a/web/app/components/workflow-app/hooks/use-workflow-template.ts b/web/app/components/workflow-app/hooks/use-workflow-template.ts index 93b8ee6d7b..de388b08f8 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-template.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-template.ts @@ -1,3 +1,5 @@ +import type { AnswerNodeType } from '@/app/components/workflow/nodes/answer/types' +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' import { useTranslation } from 'react-i18next' import { @@ -24,37 +26,41 @@ export const useWorkflowTemplate = () => { }) if (isChatMode) { + const llmData: LLMNodeType = { + ...(llmDefault.defaultValue as LLMNodeType), + desc: '', + memory: { + window: { enabled: false, size: 10 }, + query_prompt_template: '{{#sys.query#}}', + }, + selected: true, + type: llmDefault.metaData.type, + title: t(`blocks.${llmDefault.metaData.type}`, { ns: 'workflow' }), + } const { newNode: llmNode } = generateNewNode({ id: 'llm', - data: { - ...llmDefault.defaultValue, - memory: { - window: { enabled: false, size: 10 }, - query_prompt_template: '{{#sys.query#}}', - }, - selected: true, - type: llmDefault.metaData.type, - title: t(`blocks.${llmDefault.metaData.type}`, { ns: 'workflow' }), - }, + data: llmData, position: { x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET, y: START_INITIAL_POSITION.y, }, - } as Parameters[0]) + }) + const answerData: AnswerNodeType = { + ...(answerDefault.defaultValue as AnswerNodeType), + answer: `{{#${llmNode.id}.text#}}`, + desc: '', + type: answerDefault.metaData.type, + title: t(`blocks.${answerDefault.metaData.type}`, { ns: 'workflow' }), + } const { newNode: answerNode } = generateNewNode({ id: 'answer', - data: { - ...answerDefault.defaultValue, - answer: `{{#${llmNode.id}.text#}}`, - type: answerDefault.metaData.type, - title: t(`blocks.${answerDefault.metaData.type}`, { ns: 'workflow' }), - }, + data: answerData, position: { x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET * 2, y: START_INITIAL_POSITION.y, }, - } as Parameters[0]) + }) const startToLlmEdge = { id: `${startNode.id}-${llmNode.id}`,