mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
Merge origin/v3 into codex/feat/plugin-data-query-sdk-v3
This commit is contained in:
+16
-10
@@ -211,11 +211,12 @@ def configure_plugin_system_services():
|
||||
from app.application.site.query import SiteQueryService, configure_site_query_service
|
||||
from app.application.workflow import (
|
||||
WorkflowQueryService,
|
||||
configure_workflow_execution,
|
||||
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 +230,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
|
||||
@@ -240,7 +244,7 @@ def configure_plugin_system_services():
|
||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||
from app.db.oper.transferhistory import TransferHistoryOper
|
||||
from app.db.oper.user import UserOper
|
||||
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
|
||||
from app.db.oper.workflow import WorkflowOper
|
||||
|
||||
def create_sync_session() -> Session:
|
||||
"""为无显式会话的 Oper 测试入口创建独占同步 Session。"""
|
||||
@@ -255,9 +259,8 @@ def configure_plugin_system_services():
|
||||
async_=transaction_runner.async_,
|
||||
)
|
||||
|
||||
configure_workflow_legacy_writer(
|
||||
TransactionalWorkflowExecutionService(SessionFactory)
|
||||
)
|
||||
workflow_execution = TransactionalWorkflowExecutionService(SessionFactory)
|
||||
configure_workflow_execution(workflow_execution)
|
||||
|
||||
configure_api_data_ports(
|
||||
sync_session=get_db,
|
||||
@@ -301,7 +304,6 @@ def configure_plugin_system_services():
|
||||
configure_chain_data_ports(
|
||||
site=site_repository,
|
||||
subscribe=lambda: SubscribeOper(),
|
||||
workflow=lambda: WorkflowOper(),
|
||||
download_history=lambda: DownloadHistoryOper(),
|
||||
transfer_history=lambda: TransferHistoryOper(),
|
||||
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
|
||||
@@ -332,7 +334,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 +351,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(
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"application": {
|
||||
"covered_lines": 10068,
|
||||
"percent": 78.81,
|
||||
"statements": 12775
|
||||
"covered_lines": 10084,
|
||||
"percent": 78.89,
|
||||
"statements": 12782
|
||||
},
|
||||
"domain": {
|
||||
"covered_lines": 3392,
|
||||
|
||||
+17
-12
@@ -1441,8 +1441,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 6940,
|
||||
"edge_sha256": "9e3e8485c94c46a75eb577ccfc24708c80d4296b6422ec66ab9f23ef9964568f",
|
||||
"edge_count": 6944,
|
||||
"edge_sha256": "9f6be750c55150ef9e061f54ade997c328561b7950f6ec44bce6ada373d01f3e",
|
||||
"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",
|
||||
@@ -3979,7 +3979,6 @@
|
||||
"app.application.backup -> app.runtime.log",
|
||||
"app.application.chain.context -> app.application",
|
||||
"app.application.chain.context -> app.application.chain",
|
||||
"app.application.chain.context -> app.application.chain.data",
|
||||
"app.application.chain.context -> app.application.chain.events",
|
||||
"app.application.chain.context -> app.application.configuration",
|
||||
"app.application.chain.context -> app.runtime",
|
||||
@@ -4344,6 +4343,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",
|
||||
@@ -4476,10 +4477,11 @@
|
||||
"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",
|
||||
"app.chain -> app.application.chain.data",
|
||||
"app.chain -> app.application.configuration",
|
||||
"app.chain -> app.chain._messaging",
|
||||
"app.chain -> app.chain._recognition",
|
||||
@@ -5064,8 +5066,6 @@
|
||||
"app.chain.webhook -> app.schemas",
|
||||
"app.chain.webhook -> app.schemas.types",
|
||||
"app.chain.workflow -> app.application",
|
||||
"app.chain.workflow -> app.application.chain",
|
||||
"app.chain.workflow -> app.application.chain.data",
|
||||
"app.chain.workflow -> app.application.workflow",
|
||||
"app.chain.workflow -> app.chain",
|
||||
"app.chain.workflow -> app.runtime",
|
||||
@@ -5215,6 +5215,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",
|
||||
@@ -7583,6 +7585,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",
|
||||
@@ -7608,7 +7611,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",
|
||||
@@ -7730,6 +7732,11 @@
|
||||
"app.sdk._legacy.user -> app.db",
|
||||
"app.sdk._legacy.user -> app.db.oper",
|
||||
"app.sdk._legacy.user -> app.db.oper.user",
|
||||
"app.sdk._legacy.workflow -> app.application",
|
||||
"app.sdk._legacy.workflow -> app.application.workflow",
|
||||
"app.sdk._legacy.workflow -> app.db",
|
||||
"app.sdk._legacy.workflow -> app.db.oper",
|
||||
"app.sdk._legacy.workflow -> app.db.oper.workflow",
|
||||
"app.sdk.browser -> app.adapters",
|
||||
"app.sdk.browser -> app.adapters.network",
|
||||
"app.sdk.browser -> app.adapters.network.browser",
|
||||
@@ -8209,14 +8216,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",
|
||||
@@ -8385,7 +8389,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 849,
|
||||
"module_count": 850,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9179,6 +9183,7 @@
|
||||
"app.sdk._legacy.transfer",
|
||||
"app.sdk._legacy.transferpending",
|
||||
"app.sdk._legacy.user",
|
||||
"app.sdk._legacy.workflow",
|
||||
"app.sdk.browser",
|
||||
"app.sdk.cache",
|
||||
"app.sdk.config",
|
||||
|
||||
+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,
|
||||
|
||||
-28
@@ -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
|
||||
},
|
||||
@@ -482,9 +473,6 @@
|
||||
"app/db/oper/userconfig.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/workflow.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/session.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1050,9 +1038,6 @@
|
||||
"E402": 27,
|
||||
"F401": 1
|
||||
},
|
||||
"app/workflow/__init__.py": {
|
||||
"F401": 1
|
||||
},
|
||||
"scripts/architecture/task_ownership.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1219,9 +1204,6 @@
|
||||
"E402": 4,
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_chain_runtime_context.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_cli_auto_update.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1266,9 +1248,6 @@
|
||||
"tests/test_db_lazy_engine.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_db_oper_layer.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_db_oper_layer_extra.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1353,10 +1332,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
|
||||
},
|
||||
@@ -1777,9 +1752,6 @@
|
||||
"tests/test_workflow_authorization.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_workflow_execution.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_workflow_runtime_config.py": {
|
||||
"I001": 1
|
||||
}
|
||||
|
||||
+22
-8
@@ -284,9 +284,9 @@
|
||||
"app.db.workflow_oper": {
|
||||
"introduced": "v3.0.0",
|
||||
"is_package": false,
|
||||
"owner": "db",
|
||||
"replacement": "app.db.oper.workflow",
|
||||
"target": "app.db.oper.workflow"
|
||||
"owner": "sdk",
|
||||
"replacement": "app.application.workflow.WorkflowExecutionPort",
|
||||
"target": "app.sdk._legacy.workflow"
|
||||
},
|
||||
"app.domain.string": {
|
||||
"introduced": "v3.0.0",
|
||||
@@ -926,6 +926,13 @@
|
||||
"target_name": "MediaInteractionChain"
|
||||
}
|
||||
},
|
||||
"app.db.oper": {
|
||||
"WorkflowOper": {
|
||||
"replacement": "app.application.workflow.WorkflowExecutionPort",
|
||||
"target_module": "app.sdk._legacy.workflow",
|
||||
"target_name": "WorkflowOper"
|
||||
}
|
||||
},
|
||||
"app.domain.media": {
|
||||
"MEDIA_SOURCE_ALIASES": {
|
||||
"replacement": "app.schemas.media.MEDIA_SOURCE_ALIASES",
|
||||
@@ -1184,6 +1191,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 +1454,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 +1850,7 @@
|
||||
"50704edda70674af0932e769ddda40d2c21d50b134155c975d675035d7c933cd"
|
||||
],
|
||||
"producer_fingerprints": [
|
||||
"611abaaf708f0c2d555d3b963ff540c015f2155ff493ed17b92d7c7f0bd45e96"
|
||||
"b92d348c57dac3ba348079b4e71d02a0c13933bbf14b7e81188b042c3c5e2db3"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -2980,10 +2994,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,216 @@ 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_workflow_execution_chain_uses_single_application_owned_port():
|
||||
"""工作流 Chain 写端必须只使用 Application owner 的唯一配置入口。"""
|
||||
contract_path = APP_ROOT / "application" / "workflow.py"
|
||||
contract_tree = ast.parse(
|
||||
contract_path.read_text(encoding="utf-8"),
|
||||
filename=str(contract_path),
|
||||
)
|
||||
contract = next(
|
||||
node
|
||||
for node in contract_tree.body
|
||||
if isinstance(node, ast.ClassDef)
|
||||
and node.name == "WorkflowExecutionPort"
|
||||
)
|
||||
methods = {
|
||||
node.name: ast.unparse(node.returns)
|
||||
for node in contract.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.returns is not None
|
||||
}
|
||||
assert methods == {
|
||||
"start": "bool",
|
||||
"success": "bool",
|
||||
"fail": "bool",
|
||||
"step": "bool",
|
||||
"reset": "bool",
|
||||
}
|
||||
|
||||
data_path = APP_ROOT / "application" / "chain" / "data.py"
|
||||
data_tree = ast.parse(
|
||||
data_path.read_text(encoding="utf-8"),
|
||||
filename=str(data_path),
|
||||
)
|
||||
data_class = next(
|
||||
node
|
||||
for node in data_tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ChainDataPorts"
|
||||
)
|
||||
data_fields = {
|
||||
node.target.id
|
||||
for node in data_class.body
|
||||
if isinstance(node, ast.AnnAssign)
|
||||
and isinstance(node.target, ast.Name)
|
||||
}
|
||||
data_functions = {
|
||||
node.name
|
||||
for node in data_tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
assert "workflow" not in data_fields
|
||||
assert "get_chain_workflow_port" not in data_functions
|
||||
|
||||
chain_source = (APP_ROOT / "chain" / "workflow.py").read_text(encoding="utf-8")
|
||||
startup_source = (
|
||||
APP_ROOT / "startup" / "initializers" / "modules.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert chain_source.count("get_configured_workflow_execution()") == 1
|
||||
assert "get_chain_workflow_port" not in chain_source
|
||||
assert "configure_workflow_execution(workflow_execution)" in startup_source
|
||||
assert "workflow=lambda:" not in startup_source
|
||||
|
||||
|
||||
def test_chain_registry_has_no_dynamic_proxies_or_dead_context_injection():
|
||||
"""Chain registry 不得恢复零消费者动态代理或失效 data_ports 伪注入。"""
|
||||
data_path = APP_ROOT / "application" / "chain" / "data.py"
|
||||
data_tree = ast.parse(
|
||||
data_path.read_text(encoding="utf-8"),
|
||||
filename=str(data_path),
|
||||
)
|
||||
proxy_classes = {
|
||||
node.name
|
||||
for node in data_tree.body
|
||||
if isinstance(node, ast.ClassDef)
|
||||
and (node.name.endswith("PortProxy") or node.name == "_PortProxyMeta")
|
||||
}
|
||||
dynamic_getters = {
|
||||
node.name
|
||||
for node in ast.walk(data_tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "__getattr__"
|
||||
}
|
||||
assert proxy_classes == set()
|
||||
assert dynamic_getters == set()
|
||||
|
||||
context_source = (
|
||||
APP_ROOT / "application" / "chain" / "context.py"
|
||||
).read_text(encoding="utf-8")
|
||||
chain_base_source = (APP_ROOT / "chain" / "__init__.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
startup_source = (
|
||||
APP_ROOT / "startup" / "initializers" / "modules.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "data_ports" not in context_source
|
||||
assert "self.data_ports" not in chain_base_source
|
||||
assert "data_ports=" not in startup_source
|
||||
|
||||
|
||||
def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports():
|
||||
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
|
||||
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
|
||||
oper_tree = ast.parse(
|
||||
oper_path.read_text(encoding="utf-8"),
|
||||
filename=str(oper_path),
|
||||
)
|
||||
oper_class = next(
|
||||
node
|
||||
for node in oper_tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "WorkflowOper"
|
||||
)
|
||||
method_names = {
|
||||
node.name
|
||||
for node in oper_class.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
assert {"start", "success", "fail", "step", "reset"}.isdisjoint(method_names)
|
||||
assert "legacy" not in oper_path.read_text(encoding="utf-8").lower()
|
||||
|
||||
package_source = (APP_ROOT / "db" / "oper" / "__init__.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '"WorkflowOper"' not in package_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",
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.chain import context as chain_context
|
||||
from app.application.chain import data as chain_data
|
||||
from app.application.chain.context import ChainRuntimeContext
|
||||
from app.application.configuration import ChainRuntimeConfig
|
||||
from app.application.chain import context as chain_context
|
||||
from app.chain import ChainBase
|
||||
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
|
||||
|
||||
@@ -51,3 +54,42 @@ def test_no_arg_chain_uses_compatibility_context_provider(monkeypatch) -> None:
|
||||
provider.assert_called_once_with()
|
||||
assert chain.modulemanager is context.module_manager
|
||||
assert chain.pluginmanager is context.plugin_manager
|
||||
|
||||
|
||||
def test_chain_runtime_context_rejects_unconfigured_provider(monkeypatch) -> None:
|
||||
"""未由组合根配置运行上下文时必须显式拒绝无参 Chain。"""
|
||||
monkeypatch.setattr(
|
||||
chain_context,
|
||||
"_context_provider",
|
||||
chain_context._unconfigured_chain_runtime_context,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Chain 运行上下文尚未由启动组合根配置"):
|
||||
chain_context.get_chain_runtime_context()
|
||||
|
||||
|
||||
def test_chain_data_registry_rejects_unconfigured_and_returns_factories(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""数据 registry 未配置时拒绝访问,配置后按字段返回工厂实例。"""
|
||||
monkeypatch.setattr(chain_data, "_ports", None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Chain 数据端口尚未配置"):
|
||||
chain_data.get_chain_data_ports()
|
||||
|
||||
media_server = Mock()
|
||||
user = Mock()
|
||||
chain_data.configure_chain_data_ports(
|
||||
site=Mock,
|
||||
subscribe=Mock,
|
||||
download_history=Mock,
|
||||
transfer_history=Mock,
|
||||
transfer_pending=Mock,
|
||||
transfer_execution=Mock,
|
||||
media_server=lambda: media_server,
|
||||
download_failure=Mock,
|
||||
user=lambda: user,
|
||||
)
|
||||
|
||||
assert chain_data.get_chain_media_server_port() is media_server
|
||||
assert chain_data.get_chain_user_port() is user
|
||||
|
||||
@@ -6,12 +6,11 @@ Oper 层大多是模型方法的薄封装,但薄封装恰恰是最容易出错
|
||||
验证 Oper 的对外契约,而不是验证它调了哪个模型方法。
|
||||
"""
|
||||
import asyncio
|
||||
import importlib
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.oper.mediaserver import MediaServerOper
|
||||
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
||||
from app.db.models.mediaserver import MediaServerItem
|
||||
from app.db.models.plugindata import PluginData
|
||||
@@ -22,6 +21,8 @@ from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.models.user import User
|
||||
from app.db.models.userconfig import UserConfig
|
||||
from app.db.models.workflow import Workflow
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.oper.mediaserver import MediaServerOper
|
||||
from app.db.oper.plugindata import PluginDataOper
|
||||
from app.db.oper.site import SiteOper
|
||||
from app.db.oper.user import UserOper
|
||||
@@ -374,7 +375,7 @@ def test_workflow_oper_add_rejects_duplicate_name(db):
|
||||
assert oper.add(**_workflow_kwargs("op-wf")) == (False, "工作流已存在")
|
||||
|
||||
|
||||
def test_workflow_oper_exposes_lists_and_lifecycle(db):
|
||||
def test_workflow_oper_exposes_lists_and_staged_lifecycle(db):
|
||||
"""
|
||||
列表入口与生命周期方法都应透传到模型并落库。
|
||||
"""
|
||||
@@ -387,23 +388,24 @@ def test_workflow_oper_exposes_lists_and_lifecycle(db):
|
||||
assert {w.name for w in oper.list_enabled()} >= {"op-wf-life"}
|
||||
assert {w.name for w in oper.get_timer_triggered_workflows()} >= {"op-wf-life"}
|
||||
|
||||
oper.start(flow.id)
|
||||
oper.stage_start(flow.id)
|
||||
assert oper.get(flow.id).state == "R"
|
||||
oper.step(flow.id, "a1", {"n": 1})
|
||||
oper.stage_step(flow.id, "a1", {"n": 1})
|
||||
assert oper.get(flow.id).current_action == "a1"
|
||||
oper.success(flow.id, "完成")
|
||||
oper.stage_success(flow.id, "完成")
|
||||
assert oper.get(flow.id).state == "S"
|
||||
oper.fail(flow.id, "出错")
|
||||
oper.stage_fail(flow.id, "出错")
|
||||
assert oper.get(flow.id).state == "F"
|
||||
oper.reset(flow.id, reset_count=True)
|
||||
oper.stage_execution_reset(flow.id, reset_count=True)
|
||||
assert (oper.get(flow.id).state, oper.get(flow.id).run_count) == ("W", 0)
|
||||
|
||||
|
||||
def test_workflow_oper_no_session_uses_configured_uow_writer(db):
|
||||
"""旧的无 Session Oper 写入口仍可用,但事务由组合根服务持有。"""
|
||||
flow = db.add(Workflow(**_workflow_kwargs("op-wf-legacy")))
|
||||
legacy = importlib.import_module("app.db.workflow_oper")
|
||||
|
||||
assert WorkflowOper().start(flow.id) is True
|
||||
assert legacy.WorkflowOper().start(flow.id) is True
|
||||
|
||||
db.session.expire_all()
|
||||
assert WorkflowOper(db=db.session).get(flow.id).state == "R"
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,62 @@ from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas.file import FileItem
|
||||
|
||||
|
||||
def test_legacy_workflow_writes_delegate_to_configured_execution_port(monkeypatch):
|
||||
"""旧 WorkflowOper 无 Session 写入必须完整委托类型化事务端口。"""
|
||||
legacy = importlib.import_module("app.db.workflow_oper")
|
||||
calls = []
|
||||
|
||||
class ExecutionPort:
|
||||
"""记录五种旧工作流写入调用。"""
|
||||
|
||||
def start(self, workflow_id):
|
||||
"""记录启动。"""
|
||||
calls.append(("start", workflow_id))
|
||||
return True
|
||||
|
||||
def success(self, workflow_id, result=None):
|
||||
"""记录成功。"""
|
||||
calls.append(("success", workflow_id, result))
|
||||
return True
|
||||
|
||||
def fail(self, workflow_id, result):
|
||||
"""记录失败。"""
|
||||
calls.append(("fail", workflow_id, result))
|
||||
return True
|
||||
|
||||
def step(self, workflow_id, action_id, context, execution_state=None):
|
||||
"""记录步骤。"""
|
||||
calls.append(
|
||||
("step", workflow_id, action_id, context, execution_state)
|
||||
)
|
||||
return True
|
||||
|
||||
def reset(self, workflow_id, reset_count=False):
|
||||
"""记录重置。"""
|
||||
calls.append(("reset", workflow_id, reset_count))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
legacy,
|
||||
"get_configured_workflow_execution",
|
||||
lambda: ExecutionPort(),
|
||||
)
|
||||
oper = legacy.WorkflowOper()
|
||||
|
||||
assert oper.start(7) is True
|
||||
assert oper.success(7, "done") is True
|
||||
assert oper.fail(7, "failed") is True
|
||||
assert oper.step(7, "A", {"value": 1}, {"runtime": {}}) is True
|
||||
assert oper.reset(7, reset_count=True) is True
|
||||
assert calls == [
|
||||
("start", 7),
|
||||
("success", 7, "done"),
|
||||
("fail", 7, "failed"),
|
||||
("step", 7, "A", {"value": 1}, {"runtime": {}}),
|
||||
("reset", 7, True),
|
||||
]
|
||||
|
||||
|
||||
def test_legacy_subscribe_add_delegates_to_application_service(monkeypatch):
|
||||
"""旧 SubscribeOper.add 应保留 mediainfo 写入签名。"""
|
||||
legacy = importlib.import_module("app.db.subscribe_oper")
|
||||
|
||||
@@ -314,6 +314,19 @@ def test_db_refactor_legacy_modules_are_all_registered():
|
||||
assert expected <= set(MODULE_ALIASES)
|
||||
|
||||
|
||||
def test_workflow_oper_compatibility_is_only_exposed_by_overlay():
|
||||
"""旧工作流写入口只由 Legacy facade 和精确符号映射提供。"""
|
||||
legacy = importlib.import_module("app.db.workflow_oper")
|
||||
canonical = importlib.import_module("app.db.oper.workflow")
|
||||
oper_package = importlib.import_module("app.db.oper")
|
||||
|
||||
assert MODULE_ALIASES["app.db.workflow_oper"].target == "app.sdk._legacy.workflow"
|
||||
assert issubclass(legacy.WorkflowOper, canonical.WorkflowOper)
|
||||
assert legacy.WorkflowOper is not canonical.WorkflowOper
|
||||
assert oper_package.WorkflowOper is legacy.WorkflowOper
|
||||
assert "WorkflowOper" not in oper_package.__all__
|
||||
|
||||
|
||||
def test_split_user_oper_facade_exports_data_and_auth_contracts():
|
||||
"""旧 user_oper 同时提供 UserOper 与八个认证依赖。"""
|
||||
legacy = importlib.import_module("app.db.user_oper")
|
||||
@@ -410,6 +423,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 +482,8 @@ 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.db.oper"]) == {"WorkflowOper"}
|
||||
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,
|
||||
@@ -635,9 +635,24 @@ def test_workflow_chain_process_serializes_circular_context(monkeypatch):
|
||||
flows=[{"id": "flow-end", "source": "A", "target": "END", "animated": True}],
|
||||
)
|
||||
fake_oper = _FakeWorkflowOper(workflow)
|
||||
port_calls = []
|
||||
|
||||
def get_execution_port():
|
||||
"""记录单次执行获取事务端口的次数。"""
|
||||
port_calls.append(True)
|
||||
return fake_oper
|
||||
|
||||
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_execution",
|
||||
get_execution_port,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -645,6 +660,7 @@ def test_workflow_chain_process_serializes_circular_context(monkeypatch):
|
||||
|
||||
assert success is True
|
||||
assert message == ""
|
||||
assert port_calls == [True]
|
||||
assert fake_oper.succeeded is True
|
||||
saved_workflow_context = fake_oper.steps[-1]["context"]["workflow_context"]
|
||||
saved_self = saved_workflow_context["self"]
|
||||
@@ -825,7 +841,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 +920,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}
|
||||
@@ -940,7 +956,16 @@ def test_workflow_chain_rejects_execution_before_persisting_running_state(monkey
|
||||
workflowoper = _FakeWorkflowOper(workflow)
|
||||
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_execution",
|
||||
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 +995,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 = {}
|
||||
@@ -978,7 +1003,18 @@ def test_workflow_chain_releases_admitted_owner_when_start_fails(monkeypatch):
|
||||
manager._executions = {}
|
||||
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_execution",
|
||||
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 +1054,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 +1093,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)
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import app.application.workflow as workflow_application
|
||||
from app.application.workflow import (
|
||||
WorkflowDefinitionCommand,
|
||||
WorkflowExecutionCommand,
|
||||
WorkflowMutationCommand,
|
||||
WorkflowQueryService,
|
||||
WorkflowSnapshot,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +23,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()
|
||||
@@ -62,6 +88,23 @@ def _execution_command(commit_error=None):
|
||||
), repository, unit_of_work
|
||||
|
||||
|
||||
def test_workflow_execution_port_requires_explicit_configuration(monkeypatch):
|
||||
"""执行状态端口必须显式装配,并原样返回组合根登记的服务。"""
|
||||
monkeypatch.setattr(
|
||||
workflow_application,
|
||||
"_configured_workflow_execution",
|
||||
None,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="工作流执行状态事务服务尚未配置"):
|
||||
workflow_application.get_configured_workflow_execution()
|
||||
|
||||
service = Mock()
|
||||
workflow_application.configure_workflow_execution(service)
|
||||
|
||||
assert workflow_application.get_configured_workflow_execution() is service
|
||||
|
||||
|
||||
def test_execution_step_is_staged_before_unit_of_work_commit():
|
||||
"""工作流进度写入必须由应用命令暂存后统一提交。"""
|
||||
command, repository, unit_of_work = _execution_command()
|
||||
@@ -96,8 +139,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 +149,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)
|
||||
|
||||
@@ -123,6 +169,18 @@ def test_start_timer_workflow_commits_before_registering_job():
|
||||
dependencies["repository"].stage_state.assert_called_once_with(7, "W")
|
||||
|
||||
|
||||
def test_start_rejects_missing_workflow_without_transaction():
|
||||
"""工作流不存在时不得暂存状态或触发事务。"""
|
||||
command, dependencies = _command()
|
||||
|
||||
result = command.start(7)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "工作流不存在"
|
||||
dependencies["repository"].stage_state.assert_not_called()
|
||||
dependencies["unit_of_work"].commit.assert_not_called()
|
||||
|
||||
|
||||
def test_start_rejects_invalid_trigger_without_transaction():
|
||||
"""未知触发类型不得更新数据库或注册运行时触发器。"""
|
||||
command, dependencies = _command(_workflow(trigger_type="unknown"))
|
||||
|
||||
@@ -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