refactor(workflow): enforce typed query boundary

This commit is contained in:
jxxghp
2026-08-28 01:16:08 +08:00
parent 2f41780893
commit b4f8736541
35 changed files with 733 additions and 223 deletions
+12 -5
View File
@@ -214,8 +214,8 @@ def configure_plugin_system_services():
configure_workflow_query,
configure_workflow_runtime,
)
from app.workflow import WorkFlowManager
configure_workflow_runtime(lambda: WorkFlowManager())
from app.workflow import WorkflowManager
configure_workflow_runtime(lambda: WorkflowManager())
from app.application.agentdata import configure_agent_data_ports
from app.application.agenttask import (
AgentTaskExecutionService,
@@ -229,7 +229,10 @@ def configure_plugin_system_services():
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
from app.db.adapters.workflow import TransactionalWorkflowExecutionService
from app.db.adapters.workflow import (
TransactionalWorkflowExecutionService,
TransactionalWorkflowQueryRepository,
)
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
@@ -332,7 +335,12 @@ def configure_plugin_system_services():
))
configure_site_query_service(SiteQueryService(repository=site_repository()))
configure_site_health_service(SiteHealthService(repository=site_repository()))
configure_workflow_query(WorkflowQueryService(repository=WorkflowOper()))
configure_workflow_query(WorkflowQueryService(
repository=TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
))
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.plugindata import PluginDataOper
configure_agent_data_ports(
@@ -344,7 +352,6 @@ def configure_plugin_system_services():
subscribe_history=lambda: SubscribeHistoryOper(),
transfer_history=lambda: TransferHistoryOper(),
download_history=lambda: DownloadHistoryOper(),
workflow=lambda: WorkflowOper(),
plugin_data=lambda: PluginDataOper(),
)
configure_agent_task_execution(AgentTaskExecutionService(
+10 -7
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6898,
"edge_sha256": "9bceb8aaa1bb5857ba6ee075c50214455cfb4f59030cee49b3e5af751cc95972",
"edge_count": 6901,
"edge_sha256": "860590c25bd889096c9faa04f35ad3e3312e2c28ab415e57dce1d669d490e0a1",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2587,7 +2587,7 @@
"app.agent.tools.impl.query_workflows -> app.agent.tools.base",
"app.agent.tools.impl.query_workflows -> app.agent.tools.tags",
"app.agent.tools.impl.query_workflows -> app.application",
"app.agent.tools.impl.query_workflows -> app.application.agentdata",
"app.agent.tools.impl.query_workflows -> app.application.workflow",
"app.agent.tools.impl.query_workflows -> app.runtime",
"app.agent.tools.impl.query_workflows -> app.runtime.log",
"app.agent.tools.impl.read_file -> app.agent",
@@ -4340,6 +4340,8 @@
"app.application.servarr -> app.schemas.types",
"app.application.server.report -> app.schemas",
"app.application.server.report -> app.schemas.media",
"app.application.server.share -> app.application",
"app.application.server.share -> app.application.workflow",
"app.application.server.share -> app.schemas",
"app.application.server.share -> app.schemas.media",
"app.application.service -> app.schemas",
@@ -4472,6 +4474,8 @@
"app.application.transfer.workflow -> app.schemas.tmdb",
"app.application.transfer.workflow -> app.schemas.transfer",
"app.application.transfer.workflow -> app.schemas.types",
"app.application.workflow -> app.schemas",
"app.application.workflow -> app.schemas.common",
"app.chain -> app.application",
"app.chain -> app.application.chain",
"app.chain -> app.application.chain.context",
@@ -5201,6 +5205,8 @@
"app.db.adapters.workflow -> app.db.oper",
"app.db.adapters.workflow -> app.db.oper.workflow",
"app.db.adapters.workflow -> app.db.uow",
"app.db.adapters.workflow -> app.schemas",
"app.db.adapters.workflow -> app.schemas.common",
"app.db.base -> app.db",
"app.db.base -> app.db.uow",
"app.db.base -> app.runtime",
@@ -7551,6 +7557,7 @@
"app.scheduler -> app.application.plugin.runtime",
"app.scheduler -> app.application.scheduling",
"app.scheduler -> app.application.site",
"app.scheduler -> app.application.workflow",
"app.scheduler -> app.chain",
"app.scheduler -> app.chain.mediaserver",
"app.scheduler -> app.chain.recommend",
@@ -7576,7 +7583,6 @@
"app.scheduler -> app.schemas.message",
"app.scheduler -> app.schemas.system",
"app.scheduler -> app.schemas.types",
"app.scheduler -> app.schemas.workflow",
"app.schemas -> app.schemas.exports",
"app.schemas.agent -> app.schemas",
"app.schemas.agent -> app.schemas.common",
@@ -8167,14 +8173,11 @@
"app.testing.bootstrap -> app.startup.initializers.database",
"app.testing.bootstrap -> app.startup.initializers.domain",
"app.workflow -> app.application",
"app.workflow -> app.application.chain",
"app.workflow -> app.application.chain.data",
"app.workflow -> app.application.workflow",
"app.workflow -> app.foundation",
"app.workflow -> app.foundation.reflection",
"app.workflow -> app.foundation.singleton",
"app.workflow -> app.runtime",
"app.workflow -> app.runtime.config",
"app.workflow -> app.runtime.events",
"app.workflow -> app.runtime.log",
"app.workflow -> app.runtime.stop",
+8 -9
View File
@@ -848,7 +848,7 @@
"arg-type": 7
},
"app/api/dependencies/workflow.py": {
"arg-type": 4,
"arg-type": 3,
"redundant-cast": 2
},
"app/api/endpoints/agent.py": {
@@ -1107,7 +1107,7 @@
"no-any-return": 4
},
"app/application/agentdata.py": {
"attr-defined": 10
"attr-defined": 9
},
"app/application/agenttask.py": {
"type-arg": 1
@@ -1299,7 +1299,6 @@
"type-arg": 5
},
"app/application/server/share.py": {
"no-any-return": 1,
"type-arg": 6
},
"app/application/service.py": {
@@ -1588,13 +1587,13 @@
"index": 1
},
"app/chain/workflow.py": {
"arg-type": 2,
"arg-type": 1,
"assignment": 1,
"call-arg": 1,
"index": 4,
"misc": 1,
"no-any-return": 10,
"no-untyped-def": 3,
"no-any-return": 7,
"no-untyped-def": 2,
"truthy-function": 2,
"type-arg": 17,
"union-attr": 4,
@@ -3232,7 +3231,7 @@
"unused-ignore": 1
},
"app/scheduler.py": {
"arg-type": 8,
"arg-type": 7,
"attr-defined": 1,
"import-untyped": 1,
"misc": 1,
@@ -3406,7 +3405,7 @@
"misc": 1,
"no-any-return": 2,
"no-untyped-call": 33,
"no-untyped-def": 13,
"no-untyped-def": 12,
"return-value": 5
},
"app/startup/initializers/plugins.py": {
@@ -3448,7 +3447,7 @@
"no-untyped-call": 2
},
"app/workflow/__init__.py": {
"arg-type": 2,
"arg-type": 1,
"assignment": 2,
"index": 1,
"no-any-return": 1,
-19
View File
@@ -291,9 +291,6 @@
"F401": 1,
"I001": 1
},
"app/application/agentdata.py": {
"I001": 1
},
"app/application/agenttask.py": {
"I001": 1
},
@@ -380,9 +377,6 @@
"app/application/torrent_cache.py": {
"I001": 1
},
"app/application/workflow.py": {
"I001": 1
},
"app/chain/_music.py": {
"E402": 5
},
@@ -401,9 +395,6 @@
"app/db/adapters/transaction.py": {
"I001": 1
},
"app/db/adapters/workflow.py": {
"I001": 1
},
"app/db/base.py": {
"I001": 1
},
@@ -1056,9 +1047,6 @@
"E402": 27,
"F401": 1
},
"app/workflow/__init__.py": {
"F401": 1
},
"scripts/architecture/task_ownership.py": {
"I001": 1
},
@@ -1362,10 +1350,6 @@
"tests/test_health_probes.py": {
"I001": 1
},
"tests/test_host_runtime_context.py": {
"F401": 1,
"I001": 1
},
"tests/test_indexer_spider_search_url.py": {
"I001": 1
},
@@ -1786,9 +1770,6 @@
"tests/test_workflow_authorization.py": {
"I001": 1
},
"tests/test_workflow_execution.py": {
"I001": 1
},
"tests/test_workflow_runtime_config.py": {
"I001": 1
}
+12 -5
View File
@@ -1184,6 +1184,13 @@
"target_module": "app.runtime.log",
"target_name": "log_settings"
}
},
"app.workflow": {
"WorkFlowManager": {
"replacement": "app.workflow.WorkflowManager",
"target_module": "app.workflow",
"target_name": "WorkflowManager"
}
}
},
"virtual_packages": [
@@ -1440,12 +1447,12 @@
"caller": "app.workflow",
"dynamic": true,
"events": [],
"fingerprint": "042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188",
"fingerprint": "b99f557080a8ddb6dc9d2870d02c4cabc4b265b0daac086369c3bf1d51073c09",
"handler": "self._handle_event",
"invalid": false,
"method": "add_event_listener",
"priority": "<default>",
"qualname": "WorkFlowManager.register_workflow_event",
"qualname": "WorkflowManager.register_workflow_event",
"receiver_kind": "canonical_singleton",
"registration_kind": "listener"
}
@@ -1836,7 +1843,7 @@
"50704edda70674af0932e769ddda40d2c21d50b134155c975d675035d7c933cd"
],
"producer_fingerprints": [
"611abaaf708f0c2d555d3b963ff540c015f2155ff493ed17b92d7c7f0bd45e96"
"b92d348c57dac3ba348079b4e71d02a0c13933bbf14b7e81188b042c3c5e2db3"
]
}
},
@@ -2980,10 +2987,10 @@
"events": [
"EventType.WorkflowExecute"
],
"fingerprint": "611abaaf708f0c2d555d3b963ff540c015f2155ff493ed17b92d7c7f0bd45e96",
"fingerprint": "b92d348c57dac3ba348079b4e71d02a0c13933bbf14b7e81188b042c3c5e2db3",
"invalid": false,
"method": "send_event",
"qualname": "WorkFlowManager._trigger_workflow",
"qualname": "WorkflowManager._trigger_workflow",
"receiver_kind": "canonical_singleton"
},
{
+2 -2
View File
@@ -300,7 +300,7 @@
},
{
"caller": "app.workflow",
"qualname": "WorkFlowManager.register_workflow_event",
"qualname": "WorkflowManager.register_workflow_event",
"method": "add_event_listener",
"receiver_kind": "canonical_singleton",
"events": [],
@@ -309,7 +309,7 @@
"handler": "self._handle_event",
"registration_kind": "listener",
"priority": "<default>",
"fingerprint": "042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188",
"fingerprint": "b99f557080a8ddb6dc9d2870d02c4cabc4b265b0daac086369c3bf1d51073c09",
"classification": "approved_dynamic_exception",
"owner": "app.workflow",
"reason": "工作流配置在运行期决定事件类型,receiver 与 handler 仍可静态证明。"
-1
View File
@@ -14,7 +14,6 @@ def test_named_agent_data_getters_use_registered_factories(monkeypatch) -> None:
"subscribe_history": agentdata.get_agent_subscribe_history_port,
"transfer_history": agentdata.get_agent_transfer_history_port,
"download_history": agentdata.get_agent_download_history_port,
"workflow": agentdata.get_agent_workflow_port,
"plugin_data": agentdata.get_agent_plugin_data_port,
}
factories = {
+41 -31
View File
@@ -1,39 +1,49 @@
import asyncio
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
from app.application.workflow import WorkflowSnapshot
class TestQueryWorkflowsTool(unittest.TestCase):
def test_query_workflows_omits_large_result_field(self):
tool = QueryWorkflowsTool(session_id="session-1", user_id="10001")
workflow = SimpleNamespace(
id=1,
name="demo",
description="demo workflow",
state="S",
trigger_type="manual",
run_count=1,
timer=None,
event_type=None,
add_time="2026-05-08 10:00:00",
last_time="2026-05-08 10:01:00",
current_action=None,
result="x" * 10000,
)
workflow_oper = MagicMock()
workflow_oper.async_list = AsyncMock(return_value=[workflow])
def _workflow() -> WorkflowSnapshot:
"""构造 Agent 查询使用的真实工作流快照。"""
return WorkflowSnapshot(
id=1,
name="demo",
description="demo workflow",
timer=None,
trigger_type="manual",
event_type=None,
event_conditions={},
state="S",
current_action=None,
result="x" * 10000,
run_count=1,
actions=(),
flows=(),
context={},
execution_config={},
execution_state={},
add_time="2026-05-08 10:00:00",
last_time="2026-05-08 10:01:00",
)
with patch(
"app.agent.tools.impl.query_workflows.get_agent_workflow_port",
return_value=workflow_oper,
):
result = asyncio.run(tool.run())
payload = json.loads(result)
self.assertEqual(len(payload), 1)
self.assertEqual(payload[0]["name"], "demo")
self.assertNotIn("result", payload[0])
def test_query_workflows_omits_large_result_field(monkeypatch) -> None:
"""Agent 列表查询使用统一快照服务且不返回大结果字段。"""
tool = QueryWorkflowsTool(session_id="session-1", user_id="10001")
query = MagicMock()
query.list = AsyncMock(return_value=[_workflow()])
monkeypatch.setattr(
"app.agent.tools.impl.query_workflows.get_configured_workflow_query",
lambda: query,
)
result = asyncio.run(tool.run())
payload = json.loads(result)
assert len(payload) == 1
assert payload[0]["name"] == "demo"
assert "result" not in payload[0]
query.list.assert_awaited_once_with()
+87
View File
@@ -283,6 +283,93 @@ def test_retired_canonical_filenames_do_not_return():
assert leftovers == []
def test_workflow_query_contract_returns_only_typed_snapshots():
"""工作流正式查询端口不得退化为 Any 或 ORM 返回值。"""
path = APP_ROOT / "application" / "workflow.py"
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
query_classes = {
node.name: node
for node in tree.body
if isinstance(node, ast.ClassDef)
and node.name in {"WorkflowQueryRepository", "WorkflowQueryService"}
}
methods = [
node
for query_class in query_classes.values()
for node in query_class.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and not node.name.startswith("__")
and node.returns is not None
]
assert set(query_classes) == {"WorkflowQueryRepository", "WorkflowQueryService"}
assert methods
for method in methods:
annotation = ast.unparse(method.returns)
assert "Any" not in annotation
assert "WorkflowSnapshot" in annotation
def test_workflow_query_consumers_do_not_reach_raw_oper():
"""API、Agent、共享服务和运行时管理器只消费统一快照查询服务。"""
consumer_paths = (
"app/api/dependencies/workflow.py",
"app/agent/tools/impl/query_workflows.py",
"app/application/server/share.py",
"app/workflow/__init__.py",
)
violations = {}
for relative_path in consumer_paths:
source = (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
forbidden = {
name
for name in (
"WorkflowOper",
"get_agent_workflow_port",
"get_chain_workflow_port",
)
if name in source
}
if forbidden:
violations[relative_path] = sorted(forbidden)
assert violations == {}
def test_workflow_query_adapter_owns_projection_sessions():
"""唯一查询适配器必须在自有同步和异步 Session 内投影快照。"""
path = APP_ROOT / "db" / "adapters" / "workflow.py"
source = path.read_text(encoding="utf-8")
assert "class TransactionalWorkflowQueryRepository" in source
assert "session.close()" in source
assert "async with self._async_session() as session" in source
assert "_project_workflow(record)" in source
def test_agent_data_ports_do_not_duplicate_workflow_query_capability():
"""Agent 数据聚合器不得重新暴露无类型工作流读取入口。"""
source = (APP_ROOT / "application" / "agentdata.py").read_text(
encoding="utf-8"
)
assert "WorkflowPort" not in source
assert "get_agent_workflow_port" not in source
def test_host_uses_canonical_workflow_manager_name():
"""宿主代码不得继续定义或导入旧 WorkFlowManager 拼写。"""
violations = []
for path in APP_ROOT.rglob("*.py"):
relative_path = path.relative_to(APP_ROOT)
if relative_path.parts[:2] == ("runtime", "compat"):
continue
if "WorkFlowManager" in path.read_text(encoding="utf-8"):
violations.append(str(path.relative_to(PROJECT_ROOT)))
assert violations == []
def test_startup_root_contains_only_composition_packages():
"""组合根顶层只保留稳定分区,禁止再次堆叠扁平实现文件。"""
startup_root = APP_ROOT / "startup"
+2 -2
View File
@@ -137,7 +137,7 @@ def test_current_event_consumer_policy_matches_exact_reviewed_set() -> None:
]
assert dynamic_entries == [{
"caller": "app.workflow",
"qualname": "WorkFlowManager.register_workflow_event",
"qualname": "WorkflowManager.register_workflow_event",
"method": "add_event_listener",
"receiver_kind": "canonical_singleton",
"events": [],
@@ -147,7 +147,7 @@ def test_current_event_consumer_policy_matches_exact_reviewed_set() -> None:
"registration_kind": "listener",
"priority": "<default>",
"fingerprint": (
"042068d816db7e46ab4da6e96f8549af97b57bd9d75ba710cc1ff4fec7e5e188"
"b99f557080a8ddb6dc9d2870d02c4cabc4b265b0daac086369c3bf1d51073c09"
),
"classification": DYNAMIC_CLASSIFICATION,
"owner": "app.workflow",
+62 -1
View File
@@ -6,13 +6,17 @@
`run_count` 的自增必须留在 SQL 侧,否则并发执行会丢计数。
"""
import asyncio
from dataclasses import FrozenInstanceError
import pytest
from app.application.workflow import WorkflowSnapshot
from app.db import base as db_base
from app.db.adapters.workflow import TransactionalWorkflowQueryRepository
from app.db.models.workflow import Workflow
from app.db.oper.workflow import WorkflowOper
from app.db.session import async_session_scope
from app.db.session import SessionFactory, async_session_scope
from app.schemas.workflow import Workflow as WorkflowResponse
@pytest.fixture(autouse=True)
@@ -90,6 +94,63 @@ def test_workflow_oper_reuses_explicit_query_sessions(db, monkeypatch):
asyncio.run(check())
def test_query_repository_returns_detached_deep_copied_snapshot(db):
"""查询仓储必须在关闭短 Session 前投影,且 JSON 不与 ORM 记录共享。"""
workflow = _flow("wf-snapshot")
workflow.actions = [{"id": "action-1", "config": {"value": 1}}]
workflow.flows = [{"source": "action-1", "target": "end"}]
workflow.context = {"nested": {"value": 1}}
created = db.add(workflow)
repository = TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
snapshot = repository.get(created.id)
assert isinstance(snapshot, WorkflowSnapshot)
assert snapshot.name == "wf-snapshot"
with pytest.raises(FrozenInstanceError):
snapshot.name = "changed"
snapshot.actions[0]["config"]["value"] = 2
snapshot.context["nested"]["value"] = 2
refreshed = repository.get(created.id)
assert refreshed.actions[0]["config"]["value"] == 1
assert refreshed.context["nested"]["value"] == 1
def test_query_repository_async_projection_survives_session_close(db):
"""异步查询返回值在仓储退出 Session 作用域后仍可完整序列化。"""
created = db.add(_flow("wf-async-snapshot"))
repository = TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
snapshot = asyncio.run(repository.async_get(created.id))
listed = asyncio.run(repository.async_list())
assert isinstance(snapshot, WorkflowSnapshot)
assert snapshot.name == "wf-async-snapshot"
assert created.id in {item.id for item in listed}
def test_workflow_snapshot_validates_against_api_response_contract(db):
"""冻结快照可直接序列化为 API 合同且不会暴露内部执行上下文。"""
created = db.add(_flow("wf-api-snapshot"))
repository = TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
response = WorkflowResponse.model_validate(repository.get(created.id))
payload = response.model_dump()
assert payload["id"] == created.id
assert payload["name"] == "wf-api-snapshot"
assert "context" not in payload
def test_enabled_workflows_exclude_paused(db):
"""
启用列表排除暂停状态。
+8 -8
View File
@@ -14,6 +14,13 @@ from app.api.context import (
get_agent_chat_transaction,
)
from app.api.dependencies.agent import get_agent_chat_persistence
from app.application.configuration import (
ApiRuntimeConfig,
ChainRuntimeConfig,
RuntimeConfiguration,
RuntimeSettingsService,
SchedulerRuntimeConfig,
)
from app.startup import lifecycle
from app.startup.composition.context import (
AgentChatRuntime,
@@ -26,14 +33,6 @@ from app.startup.composition.context import (
SubscriptionRuntime,
WorkflowRuntime,
)
from app.application.configuration import (
ApiRuntimeConfig,
ChainRuntimeConfig,
RuntimeConfiguration,
RuntimeSettingsService,
SchedulerRuntimeConfig,
)
PROJECT_ROOT = Path(__file__).parents[1]
@@ -153,6 +152,7 @@ def _runtime() -> HostRuntime:
outbox=_Outbox,
),
workflow=WorkflowRuntime(
query=SimpleNamespace(),
repository=_Repository,
system_config=lambda: _Repository(object()),
),
+11
View File
@@ -410,6 +410,16 @@ def test_chain_media_legacy_scraping_symbols_resolve_to_scraping_chain():
assert legacy_media.ScrapingConfig is canonical_scraping.ScrapingConfig
def test_workflow_manager_legacy_name_resolves_only_through_symbol_overlay():
"""旧 WorkFlowManager 仍可显式导入,但不进入 canonical 模块公开面。"""
install_legacy_import_hook()
workflow_module = importlib.import_module("app.workflow")
assert workflow_module.WorkFlowManager is workflow_module.WorkflowManager
assert "WorkFlowManager" not in vars(workflow_module)
assert "WorkFlowManager" not in workflow_module.__all__
def test_rules_domain_legacy_modules_resolve_to_rules():
"""规则域收敛后,filter/filter_rules 旧路径应复用 rules 模块。"""
canonical = importlib.import_module("app.application.rules")
@@ -459,6 +469,7 @@ def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path):
def test_symbol_alias_manifest_covers_all_moved_public_symbols():
"""符号级映射清单应覆盖媒体身份、整理工作项、刮削拆分与消息/通知命名统一的旧入口。"""
assert set(SYMBOL_ALIASES["app.workflow"]) == {"WorkFlowManager"}
assert set(SYMBOL_ALIASES["app.domain.media"]) == {
"MEDIA_SOURCE_ALIASES",
"MEDIA_SOURCE_PREFIXES",
+83 -1
View File
@@ -1,8 +1,34 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from app.application.server.share import ServerSharingService
from app.application.workflow import WorkflowSnapshot
def _workflow(*, actions=(), flows=()) -> WorkflowSnapshot:
"""构造中心服务分享使用的真实工作流快照。"""
return WorkflowSnapshot(
id=1,
name="Demo Workflow",
description="demo",
timer=None,
trigger_type="manual",
event_type=None,
event_conditions={},
state="W",
current_action=None,
result=None,
run_count=0,
actions=actions,
flows=flows,
context={"private": True},
execution_config={},
execution_state={},
add_time=None,
last_time=None,
)
def _service(**overrides) -> ServerSharingService:
@@ -66,7 +92,7 @@ def test_subscribe_share_builds_public_payload_and_clears_cache_after_success():
def test_workflow_validation_stops_before_transport():
"""缺少动作或流程的工作流不会进入中心服务传输。"""
sender = Mock()
workflow = SimpleNamespace(actions=[], flows=[{"id": 1}])
workflow = _workflow(flows=({"id": 1},))
service = _service(
workflow_provider=Mock(return_value=workflow),
workflow_sender=sender,
@@ -84,6 +110,36 @@ def test_workflow_validation_stops_before_transport():
sender.assert_not_called()
def test_workflow_share_serializes_snapshot_without_local_fields():
"""同步工作流分享从冻结快照生成兼容载荷并剔除本地上下文。"""
sender = Mock(return_value=SimpleNamespace(status_code=200))
workflow = _workflow(
actions=({"id": "action-1"},),
flows=({"source": "action-1", "target": "end"},),
)
service = _service(
workflow_provider=Mock(return_value=workflow),
workflow_sender=sender,
)
result = service.share_workflow(
enabled=True,
workflow_id=1,
share_title="Title",
share_comment="Comment",
share_user="User",
)
assert result == (True, "")
payload = sender.call_args.args[0]
assert "id" not in payload
assert "context" not in payload
assert json.loads(payload["actions"]) == [{"id": "action-1"}]
assert json.loads(payload["flows"]) == [
{"source": "action-1", "target": "end"}
]
def test_async_subscribe_share_uses_async_reader_and_transport():
"""异步分享路径不会回退到同步数据库或网络端口。"""
subscribe = SimpleNamespace(to_dict=lambda: {
@@ -110,3 +166,29 @@ def test_async_subscribe_share_uses_async_reader_and_transport():
assert result == (True, "")
reader.assert_awaited_once_with(1)
sender.assert_awaited_once()
def test_async_workflow_share_uses_snapshot_reader_and_transport():
"""异步工作流分享复用同一快照契约且不回退同步端口。"""
workflow = _workflow(
actions=({"id": "action-1"},),
flows=({"source": "action-1", "target": "end"},),
)
reader = AsyncMock(return_value=workflow)
sender = AsyncMock(return_value=SimpleNamespace(status_code=200))
service = _service(
async_workflow_provider=reader,
async_workflow_sender=sender,
)
result = asyncio.run(service.async_share_workflow(
enabled=True,
workflow_id=1,
share_title="Title",
share_comment="Comment",
share_user="User",
))
assert result == (True, "")
reader.assert_awaited_once_with(1)
sender.assert_awaited_once()
+2 -2
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from app.schemas.download import DownloadTask
from app.schemas.file import FileItem
from app.schemas.workflow import ActionContext, ActionResult
from app.workflow import WorkFlowManager
from app.workflow import WorkflowManager
from app.workflow.actions import BaseAction
from app.workflow.actions import fetch_downloads as fetch_downloads_module
from app.workflow.actions import fetch_torrents as fetch_torrents_module
@@ -239,7 +239,7 @@ def test_execute_with_inputs_maps_contract_inputs_outputs_and_runtime(monkeypatc
def test_workflow_manager_list_actions_exposes_contract():
"""动作列表应返回固定输入输出契约。"""
manager = object.__new__(WorkFlowManager)
manager = object.__new__(WorkflowManager)
manager._actions = {"FetchRssAction": FetchRssAction}
actions = manager.list_actions()
+23 -6
View File
@@ -6,11 +6,11 @@ from types import SimpleNamespace
import pytest
from app import workflow as workflow_package
from app.chain import workflow as workflow_module
from app.runtime.correlation import correlation_scope, get_correlation_id
from app.schemas.types import EventType
from app.schemas.workflow import Action, ActionContext, ActionResult
from app import workflow as workflow_package
def _build_workflow(current_action=None, context=None, actions=None, flows=None,
@@ -638,6 +638,11 @@ def test_workflow_chain_process_serializes_circular_context(monkeypatch):
monkeypatch.setattr(workflow_module, "get_workflow_manager", lambda: fake_manager)
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", lambda: fake_oper)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_query",
lambda: SimpleNamespace(get_sync=lambda _workflow_id: workflow),
)
monkeypatch.setattr(workflow_module.runtime_stop_state, "resume_workflow", lambda workflow_id: None)
monkeypatch.setattr(workflow_module.runtime_stop_state, "is_workflow_stopped", lambda workflow_id: False)
@@ -825,7 +830,7 @@ def test_workflow_manager_shutdown_retains_blocked_execution_for_retry(monkeypat
release.wait()
return ActionResult(success=True, context=context)
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.RLock()
manager._actions = {"BlockingAction": BlockingAction}
manager._event_workflows = {}
@@ -904,7 +909,7 @@ def test_workflow_manager_shutdown_continues_across_owner_failures():
self.manager.unregister_execution(self)
return True
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.RLock()
action_marker = object()
manager._actions = {"FakeAction": action_marker}
@@ -941,6 +946,11 @@ def test_workflow_chain_rejects_execution_before_persisting_running_state(monkey
manager = RejectingWorkflowManager([])
monkeypatch.setattr(workflow_module, "get_workflow_manager", lambda: manager)
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", lambda: workflowoper)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_query",
lambda: SimpleNamespace(get_sync=lambda _workflow_id: workflow),
)
def unexpected_resume(_workflow_id: int) -> None:
"""拒绝准入时若仍恢复停止标记则立即暴露回归。"""
@@ -970,7 +980,7 @@ def test_workflow_chain_releases_admitted_owner_when_start_fails(monkeypatch):
_ = wid
raise RuntimeError("start failed")
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.RLock()
manager._actions = {"FakeAction": object()}
manager._event_workflows = {}
@@ -979,6 +989,13 @@ def test_workflow_chain_releases_admitted_owner_when_start_fails(monkeypatch):
workflowoper = FailingWorkflowOper(_build_workflow())
monkeypatch.setattr(workflow_module, "get_workflow_manager", lambda: manager)
monkeypatch.setattr(workflow_module, "get_chain_workflow_port", lambda: workflowoper)
monkeypatch.setattr(
workflow_module,
"get_configured_workflow_query",
lambda: SimpleNamespace(
get_sync=lambda _workflow_id: workflowoper.workflow
),
)
monkeypatch.setattr(workflow_module.runtime_stop_state, "resume_workflow", lambda _workflow_id: None)
with pytest.raises(RuntimeError, match="start failed"):
@@ -1018,7 +1035,7 @@ class _FakeEventManager:
def test_workflow_event_listener_keeps_shared_handler_until_last_workflow(monkeypatch):
"""同一事件下移除单个工作流时不应断开其他工作流监听。"""
fake_eventmanager = _FakeEventManager()
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.Lock()
manager._event_workflows = {}
@@ -1057,7 +1074,7 @@ def test_workflow_manager_retries_action_until_success(monkeypatch):
return ActionResult(success=False, message="第一次失败", context=context)
return ActionResult(success=True, message="第二次成功", context=context, outputs={"ok": True})
manager = object.__new__(workflow_package.WorkFlowManager)
manager = object.__new__(workflow_package.WorkflowManager)
manager._actions = {"RetryAction": RetryAction}
monkeypatch.setattr(workflow_package.runtime_stop_state, "is_workflow_stopped", lambda workflow_id: False)
+30 -2
View File
@@ -8,6 +8,7 @@ from app.application.workflow import (
WorkflowExecutionCommand,
WorkflowMutationCommand,
WorkflowQueryService,
WorkflowSnapshot,
)
@@ -21,6 +22,30 @@ def _workflow(trigger_type="timer", timer="0 0 * * *", event_type="DownloadAdded
)
def _snapshot() -> WorkflowSnapshot:
"""构造查询服务返回的冻结工作流快照。"""
return WorkflowSnapshot(
id=7,
name="query",
description=None,
timer="0 0 * * *",
trigger_type="timer",
event_type=None,
event_conditions={},
state="W",
current_action=None,
result=None,
run_count=0,
actions=(),
flows=(),
context={},
execution_config={},
execution_state={},
add_time=None,
last_time=None,
)
def _command(workflow=None, commit_error=None):
"""构造可观察工作流事务与运行时副作用的命令。"""
repository = Mock()
@@ -96,8 +121,9 @@ def test_execution_commit_failure_rolls_back():
async def test_workflow_query_service_delegates_list_and_get_to_repository():
"""工作流查询服务只调用读取端口,不持有数据库会话或事务。"""
repository = Mock()
repository.async_list = AsyncMock(return_value=[_workflow()])
repository.async_get = AsyncMock(return_value=_workflow())
snapshot = _snapshot()
repository.async_list = AsyncMock(return_value=[snapshot])
repository.async_get = AsyncMock(return_value=snapshot)
service = WorkflowQueryService(repository)
listed = await service.list()
@@ -105,6 +131,8 @@ async def test_workflow_query_service_delegates_list_and_get_to_repository():
assert listed == repository.async_list.return_value
assert fetched == repository.async_get.return_value
assert all(isinstance(item, WorkflowSnapshot) for item in listed)
assert isinstance(fetched, WorkflowSnapshot)
repository.async_list.assert_awaited_once_with()
repository.async_get.assert_awaited_once_with(7)
+1 -1
View File
@@ -18,7 +18,7 @@ def test_workflow_runtime_facade_preserves_registered_identity(monkeypatch) -> N
def test_workflow_runtime_facade_fails_before_composition(monkeypatch) -> None:
"""未装配时不得隐式创建第二个 WorkFlowManager Singleton。"""
"""未装配时不得隐式创建第二个 WorkflowManager Singleton。"""
monkeypatch.setattr(
workflow_application,
"_workflow_runtime_provider",