mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor(workflow): enforce typed query boundary
This commit is contained in:
+12
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 仍可静态证明。"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
启用列表排除暂停状态。
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user