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.
This commit is contained in:
-LAN-
2026-05-11 13:44:13 +08:00
parent 47dc084659
commit c1e2c2e1ee
6 changed files with 104 additions and 21 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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()

View File

@ -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

View File

@ -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"}]

View File

@ -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<typeof generateNewNode>[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<typeof generateNewNode>[0])
})
const startToLlmEdge = {
id: `${startNode.id}-${llmNode.id}`,