From 32cdcfcc2ffb291209d95b18d63771c792312488 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 29 Aug 2026 18:34:36 +0800 Subject: [PATCH] refactor(startup): centralize runtime composition --- app/startup/composition/agent.py | 42 +-- app/startup/composition/chain.py | 45 +-- app/startup/composition/context.py | 17 +- app/startup/composition/database.py | 58 +--- app/startup/composition/runtime.py | 280 ++++++++++++++++++ app/startup/composition/security.py | 28 +- app/startup/initializers/modules.py | 170 ++--------- docs/architecture-optimization-checklist.md | 13 +- docs/architecture-overview.md | 19 +- docs/rules/05-architecture.md | 22 +- .../architecture/dependency-baseline.json | 90 +++--- .../fixtures/architecture/mypy-baseline.json | 4 +- .../startup-performance-baseline.json | 6 +- tests/test_agent_composition.py | 18 +- tests/test_architecture_dependencies.py | 250 ++++++++-------- tests/test_chain_runtime_context.py | 25 +- tests/test_configuration_initializer.py | 9 +- tests/test_host_runtime_context.py | 37 ++- tests/test_runtime_composition.py | 229 ++++++++++++++ 19 files changed, 853 insertions(+), 509 deletions(-) create mode 100644 app/startup/composition/runtime.py create mode 100644 tests/test_runtime_composition.py diff --git a/app/startup/composition/agent.py b/app/startup/composition/agent.py index cfffc74d0..f25368eaa 100644 --- a/app/startup/composition/agent.py +++ b/app/startup/composition/agent.py @@ -13,7 +13,6 @@ from app.application.agenttask import ( configure_agent_task_execution, reset_agent_task_execution, ) -from app.application.history import DownloadHistoryRepository, TransferHistoryRepository from app.application.messaging.chat import ( AgentChatPersistenceService, AgentChatService, @@ -22,12 +21,6 @@ from app.application.messaging.chat import ( reset_agent_chat_persistence, reset_agent_chat_service, ) -from app.application.site.contract import SiteRepository -from app.application.subscription.contract import ( - SubscriptionHistoryQueryPort, - SubscriptionRepository, -) -from app.application.transfer.execution import TransferExecutionRepository from app.db.adapters.agent import ( SessionAgentTaskRepository, TransactionalAgentTaskRepository, @@ -35,16 +28,15 @@ from app.db.adapters.agent import ( ) from app.db.oper.agentchat import AgentChatOper from app.db.oper.systemconfig import SystemConfigOper -from app.db.session import SessionFactory, async_session_scope, get_async_db -from app.db.uow import SqlAlchemyAsyncUnitOfWork +from app.db.session import SessionFactory, async_session_scope from app.startup.composition.context import ( AgentChatRepositoryFactory, - AgentChatRuntime, ) from app.startup.composition.database import ( DatabaseRuntime, build_transactional_user_repository, ) +from app.startup.composition.runtime import RuntimeDependencies from app.startup.composition.subscription import ( async_rule_group_mutation_scope, delete_subscribe_scope, @@ -60,7 +52,8 @@ class AgentComposition: """保存一个 lifespan 内共享的 Agent 数据、会话与任务对象。""" data: AgentDataContext - chat: AgentChatRuntime + chat_repository: AgentChatRepositoryFactory + persistence: AgentChatPersistenceService tasks: TransactionalAgentTaskRepository execution: AgentTaskExecutionService @@ -69,12 +62,7 @@ def compose_agent( *, runtime: DatabaseRuntime, system_config: SystemConfigOper, - site: SiteRepository, - subscription: SubscriptionRepository, - subscription_history: SubscriptionHistoryQueryPort, - transfer_history: TransferHistoryRepository, - transfer_execution: TransferExecutionRepository, - download_history: DownloadHistoryRepository, + dependencies: RuntimeDependencies, ) -> AgentComposition: """在数据库 worker 启动后构造共享的 Agent 数据与任务服务。""" persistence = AgentChatPersistenceService( @@ -91,28 +79,24 @@ def compose_agent( chat_persistence=persistence, tasks=task_repository, users=build_transactional_user_repository(), - sites=site, - subscriptions=subscription, + sites=dependencies.site, + subscriptions=dependencies.subscription, subscription_mutation_scope=subscription_mutation_scope, subscription_delete_scope=delete_subscribe_scope, async_rule_group_mutation_scope=partial( async_rule_group_mutation_scope, system_config.publish_many, ), - subscription_history=subscription_history, - transfer_history=transfer_history, - transfer_execution=transfer_execution, - download_history=download_history, + subscription_history=dependencies.subscription_history, + transfer_history=dependencies.transfer_history, + transfer_execution=dependencies.transfer_execution, + download_history=dependencies.download_history, plugin_data=TransactionalPluginDataRepository(async_session_scope), ) return AgentComposition( data=data, - chat=AgentChatRuntime( - async_session=get_async_db, - repository=chat_repository, - transaction=SqlAlchemyAsyncUnitOfWork, - persistence=persistence, - ), + chat_repository=chat_repository, + persistence=persistence, tasks=task_repository, execution=AgentTaskExecutionService( repository=SessionAgentTaskRepository, diff --git a/app/startup/composition/chain.py b/app/startup/composition/chain.py index 2162533d9..51d091b95 100644 --- a/app/startup/composition/chain.py +++ b/app/startup/composition/chain.py @@ -12,17 +12,11 @@ from app.application.image import ( configure_wallpaper_providers, reset_wallpaper_providers, ) -from app.application.messaging.message import MessageHelper, MessageQueueManager -from app.application.transfer.execution import TransferExecutionRepository from app.chain.mediaserver import MediaServerChain from app.chain.tmdb import TmdbChain from app.db.adapters.chain import TransactionalChainDurableEventWriter from app.db.adapters.download import TransactionalDownloadFailureRepository -from app.db.adapters.history.download import TransactionalDownloadHistoryRepository -from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository from app.db.adapters.mediaserver import TransactionalMediaServerRepository -from app.db.adapters.site import TransactionalSiteRepository -from app.db.adapters.subscription import TransactionalSubscriptionRepository from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository from app.db.oper.message import MessageOper from app.db.oper.systemconfig import SystemConfigOper @@ -34,6 +28,7 @@ from app.runtime.extensions.module_manager import ModuleManager from app.runtime.extensions.plugin_manager import PluginManager from app.runtime.stop import runtime_stop_state from app.startup.composition.database import build_transactional_user_repository +from app.startup.composition.runtime import RuntimeDependencies from app.startup.composition.subscription import ( delete_subscribe_scope, rule_group_mutation_scope, @@ -56,14 +51,8 @@ def execute_legacy_transfer_command(**kwargs: Any) -> Any: def build_chain_runtime_context( *, - message_helper: MessageHelper, - message_queue: MessageQueueManager, + dependencies: RuntimeDependencies, system_config: SystemConfigOper, - site: TransactionalSiteRepository, - subscription: TransactionalSubscriptionRepository, - download_history: TransactionalDownloadHistoryRepository, - transfer_history: TransactionalTransferHistoryRepository, - transfer_execution: TransferExecutionRepository, configuration: Callable[[], ChainRuntimeConfig], ) -> ChainRuntimeContext: """创建 Chain 无参兼容入口共享的运行时对象与数据端口。""" @@ -72,13 +61,13 @@ def build_chain_runtime_context( plugin_manager=PluginManager(), event_manager=_event_manager_factory(), message_oper=MessageOper(), - message_helper=message_helper, + message_helper=dependencies.message_helper, file_cache=FileCache(), async_file_cache=AsyncFileCache(), - message_queue=message_queue, + message_queue=dependencies.message_queue, module_dispatcher_factory=ModuleInvocationDispatcher, - site_repository=site, - subscription_repository=subscription, + site_repository=dependencies.site, + subscription_repository=dependencies.subscription, subscription_mutation_scope=subscription_mutation_scope, sync_subscription_mutation_scope=sync_subscription_mutation_scope, subscription_delete_scope=delete_subscribe_scope, @@ -92,10 +81,10 @@ def build_chain_runtime_context( site_reference_mutation_scope, system_config.publish_many, ), - download_history_repository=download_history, - transfer_history_repository=transfer_history, + download_history_repository=dependencies.download_history, + transfer_history_repository=dependencies.transfer_history, transfer_admission_repository=TransactionalTransferAdmissionRepository(SessionFactory), - transfer_execution_repository=transfer_execution, + transfer_execution_repository=dependencies.transfer_execution, media_server_repository=TransactionalMediaServerRepository(SessionFactory), download_failure_repository=TransactionalDownloadFailureRepository(SessionFactory), user_repository=build_transactional_user_repository(), @@ -118,27 +107,15 @@ def configure_wallpaper_services() -> None: def configure_chain_runtime_context( *, - message_helper: MessageHelper, - message_queue: MessageQueueManager, + dependencies: RuntimeDependencies, system_config: SystemConfigOper, - site: TransactionalSiteRepository, - subscription: TransactionalSubscriptionRepository, - download_history: TransactionalDownloadHistoryRepository, - transfer_history: TransactionalTransferHistoryRepository, - transfer_execution: TransferExecutionRepository, configuration: Callable[[], ChainRuntimeConfig], ) -> None: """登记按需构造的 Chain 上下文,保持无参 Chain 的插件兼容合同。""" configure_chain_runtime_context_provider( lambda: build_chain_runtime_context( - message_helper=message_helper, - message_queue=message_queue, + dependencies=dependencies, system_config=system_config, - site=site, - subscription=subscription, - download_history=download_history, - transfer_history=transfer_history, - transfer_execution=transfer_execution, configuration=configuration, ) ) diff --git a/app/startup/composition/context.py b/app/startup/composition/context.py index 495e2e26d..1c07b6621 100644 --- a/app/startup/composition/context.py +++ b/app/startup/composition/context.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable, Generator from contextlib import AbstractAsyncContextManager, AbstractContextManager -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Protocol from app.application.agent import AgentDataContext @@ -172,6 +172,7 @@ class AuthenticationRuntime: """认证、用户管理与 PassKey API 的显式数据工厂。""" user_repository: RepositoryFactory + passkey_repository: RepositoryFactory standalone_user: StandaloneRepositoryFactory system_config: StandaloneRepositoryFactory passkey: StandaloneRepositoryFactory @@ -225,15 +226,9 @@ class SubscriptionRuntime: outbox: AsyncOutboxFactory dispatch_store: AsyncOutboxDispatchStore batch_writer: SubscriptionBatchWriterFactory - rule_group_mutation_scope: Callable[ - [], AbstractContextManager[SyncRuleGroupMutationService] - ] - async_rule_group_mutation_scope: Callable[ - [], AbstractAsyncContextManager[AsyncRuleGroupMutationService] - ] - site_reference_mutation_scope: Callable[ - [], AbstractContextManager[SyncSiteReferenceMutationService] - ] + rule_group_mutation_scope: Callable[[], AbstractContextManager[SyncRuleGroupMutationService]] + async_rule_group_mutation_scope: Callable[[], AbstractAsyncContextManager[AsyncRuleGroupMutationService]] + site_reference_mutation_scope: Callable[[], AbstractContextManager[SyncSiteReferenceMutationService]] @dataclass(frozen=True, slots=True) @@ -251,4 +246,4 @@ class HostRuntime: workflow: WorkflowRuntime configuration: RuntimeConfiguration settings: RuntimeSettingsService - tasks: TaskRegistry = field(default_factory=TaskRegistry) + tasks: TaskRegistry diff --git a/app/startup/composition/database.py b/app/startup/composition/database.py index 67e73779e..e19807eec 100644 --- a/app/startup/composition/database.py +++ b/app/startup/composition/database.py @@ -9,11 +9,6 @@ from app.adapters.system.backup.database import ( SQLiteBackupBackend, ) from app.adapters.system.backup.files import BackupFiles -from app.api.data import ( - ApiDataPorts, - configure_api_data_runtime, - reset_api_data_runtime, -) from app.application.backup import BackupPolicy, DatabaseBackupService from app.application.database import ( DatabaseGovernance, @@ -39,35 +34,21 @@ from app.application.workflow import ( configure_workflow_query, reset_workflow_query, ) -from app.db.adapters.history.download import SessionDownloadHistoryRepository from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore from app.db.adapters.plugininstallation import TransactionalPluginInstallationStore from app.db.adapters.query import SqlAlchemyDataQueryAdapter -from app.db.adapters.site import SessionSiteRepository -from app.db.adapters.subscription import ( - SessionSubscriptionHistoryRepository, - SessionSubscriptionRepository, -) from app.db.adapters.transaction import TransactionalWriteRunner -from app.db.adapters.user import SqlAlchemyUserRepository, TransactionalUserRepository +from app.db.adapters.user import TransactionalUserRepository from app.db.adapters.workflow import TransactionalWorkflowQueryRepository from app.db.engine import get_engine from app.db.health import probe_database from app.db.maintenance import DatabaseCleanupRepository -from app.db.oper.mediaserver import MediaServerOper -from app.db.oper.message import MessageOper -from app.db.oper.passkey import PassKeyOper from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.workflow import WorkflowOper from app.db.session import ( SessionFactory, async_session_scope, - get_async_db, - get_db, ) from app.db.uow import ( - SqlAlchemyAsyncUnitOfWork, - SqlAlchemyUnitOfWork, configure_transaction_runners, reset_transaction_runners, ) @@ -87,7 +68,6 @@ class DatabaseRuntime: class DatabaseComposition: """保存初始化器后续装配所需的数据库查询能力。""" - api_data: ApiDataPorts data_query: DataQueryService plugin_persistence: PluginPersistenceService workflow_query: WorkflowQueryService @@ -148,7 +128,7 @@ def compose_database_services( runtime: DatabaseRuntime, system_config: SystemConfigOper, ) -> DatabaseComposition: - """组合查询、插件持久化与旧 API 数据 Facade 所需实现。""" + """组合查询、插件持久化与工作流查询所需实现。""" data_query_adapter = SqlAlchemyDataQueryAdapter(SessionFactory) data_query = DataQueryService( subscriptions=data_query_adapter, @@ -170,30 +150,6 @@ def compose_database_services( ) ) return DatabaseComposition( - api_data=ApiDataPorts( - sync_session=get_db, - async_session=get_async_db, - repositories={ - "download_history": SessionDownloadHistoryRepository, - "media_server": MediaServerOper, - "message": MessageOper, - "passkey": PassKeyOper, - "site": SessionSiteRepository, - "subscribe": SessionSubscriptionRepository, - "subscribe_history": SessionSubscriptionHistoryRepository, - "user": SqlAlchemyUserRepository, - "workflow": WorkflowOper, - }, - standalone={ - "passkey": PassKeyOper, - "system_config": SystemConfigOper, - "user": build_transactional_user_repository, - }, - unit_of_work={ - "async": SqlAlchemyAsyncUnitOfWork, - "sync": SqlAlchemyUnitOfWork, - }, - ), data_query=data_query, plugin_persistence=plugin_persistence, workflow_query=workflow_query, @@ -201,17 +157,15 @@ def compose_database_services( def publish_database_services(composition: DatabaseComposition) -> None: - """一次性发布当前 lifespan 的查询、插件持久化和旧 API 数据端口。""" + """一次性发布当前 lifespan 的查询与插件持久化服务。""" configure_data_query_service(composition.data_query) configure_plugin_persistence(composition.plugin_persistence) configure_workflow_query(composition.workflow_query) - configure_api_data_runtime(composition.api_data) def reset_database_services() -> None: """撤销当前 lifespan 发布的全部数据库派生服务。""" reset_transaction_runners() - reset_api_data_runtime() reset_workflow_query() reset_plugin_persistence() reset_data_query_service() @@ -250,7 +204,7 @@ def configure_database() -> None: def read_backup_policy() -> BackupPolicy: """读取一次可热更新的数据库备份目录与保留策略。""" return BackupPolicy( - root=get_runtime_setting('DATABASE_BACKUP_PATH'), - retention_days=get_runtime_setting('DB_BACKUP_RETENTION_DAYS'), - max_count=get_runtime_setting('DB_BACKUP_MAX_COUNT'), + root=get_runtime_setting("DATABASE_BACKUP_PATH"), + retention_days=get_runtime_setting("DB_BACKUP_RETENTION_DAYS"), + max_count=get_runtime_setting("DB_BACKUP_MAX_COUNT"), ) diff --git a/app/startup/composition/runtime.py b/app/startup/composition/runtime.py new file mode 100644 index 000000000..79905cf70 --- /dev/null +++ b/app/startup/composition/runtime.py @@ -0,0 +1,280 @@ +"""宿主运行时、领域投影与共享依赖的唯一组合根。""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING, Callable, cast + +from app.api.data import ( + ApiDataPorts, + configure_api_data_runtime, + reset_api_data_runtime, +) +from app.application.history import ( + DownloadHistoryRepository, + TransferHistoryRepository, + configure_transfer_history_repository, + reset_transfer_history_repository, +) +from app.application.site.contract import SiteRepository +from app.application.site.health import ( + SiteHealthService, + configure_site_health_service, + reset_site_health_service, +) +from app.application.site.query import ( + SiteQueryService, + configure_site_query_service, + reset_site_query_service, +) +from app.application.subscription.contract import ( + SubscriptionHistoryQueryPort, + SubscriptionRepository, +) +from app.application.transfer.execution import TransferExecutionRepository +from app.runtime.tasks import TaskRegistry +from app.startup.composition.context import ( + AgentChatRuntime, + AuthenticationRuntime, + HistoryRuntime, + HostRuntime, + MessagingRuntime, + PersistenceRuntime, + RepositoryFactory, + SiteRuntime, + SubscriptionRuntime, + WorkflowRuntime, +) + +if TYPE_CHECKING: + from app.application.messaging.message import MessageHelper, MessageQueueManager + from app.startup.composition.agent import AgentComposition + from app.startup.composition.configuration import ConfigurationComposition + from app.startup.composition.database import DatabaseComposition + from app.startup.composition.security import SecurityComposition + + +@dataclass(frozen=True, slots=True) +class RuntimeDependencies: + """保存 HostRuntime、Agent 与 Chain 复用的同一批有状态依赖。""" + + download_history: DownloadHistoryRepository + transfer_history: TransferHistoryRepository + site: SiteRepository + subscription: SubscriptionRepository + subscription_history: SubscriptionHistoryQueryPort + transfer_execution: TransferExecutionRepository + message_helper: MessageHelper + message_queue: MessageQueueManager + + +@dataclass(frozen=True, slots=True) +class RuntimeInputs: + """描述构造 HostRuntime 所需的已完成组合输入。""" + + configuration: ConfigurationComposition + database: DatabaseComposition + agent: AgentComposition + authentication: SecurityComposition + dependencies: RuntimeDependencies + tasks: TaskRegistry + + +@dataclass(frozen=True, slots=True) +class RuntimeComposition: + """保存宿主运行时及其兼容与应用服务投影。""" + + runtime: HostRuntime + dependencies: RuntimeDependencies + api_data: ApiDataPorts + site_query: SiteQueryService + site_health: SiteHealthService + + +def compose_runtime_dependencies() -> RuntimeDependencies: + """构造一个 lifespan 内由 Agent、Chain 与 HostRuntime 共享的依赖。""" + # 具体 DB adapter 延迟到执行期加载,导入组合 owner 不得启动或牵入数据库 worker。 + from app.application.messaging.message import MessageHelper, MessageQueueManager + from app.db.adapters.history.download import TransactionalDownloadHistoryRepository + from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository + from app.db.adapters.site import TransactionalSiteRepository + from app.db.adapters.subscription import ( + TransactionalSubscriptionHistoryRepository, + TransactionalSubscriptionRepository, + ) + from app.db.adapters.transfer.execution import ( + TransactionalTransferExecutionRepository, + ) + from app.db.session import SessionFactory, async_session_scope + + message_helper_factory: Callable[[], MessageHelper] = MessageHelper + return RuntimeDependencies( + download_history=TransactionalDownloadHistoryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ), + transfer_history=TransactionalTransferHistoryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ), + site=TransactionalSiteRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ), + subscription=TransactionalSubscriptionRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ), + subscription_history=TransactionalSubscriptionHistoryRepository( + async_session=async_session_scope, + ), + transfer_execution=TransactionalTransferExecutionRepository(SessionFactory), + message_helper=message_helper_factory(), + message_queue=MessageQueueManager(auto_start=False), + ) + + +def compose_runtime(inputs: RuntimeInputs) -> RuntimeComposition: + """从同一批组合输入构造 HostRuntime 及旧 API 数据投影。""" + # 领域 Runtime 的具体持久化投影只在组合阶段解析,保持模块冷导入合同。 + from app.db.adapters.history.download import SessionDownloadHistoryRepository + from app.db.adapters.history.transfer import SessionTransferHistoryRepository + from app.db.adapters.outbox import ( + SqlAlchemyAsyncOutboxDispatchStore, + SqlAlchemyAsyncOutboxStager, + ) + from app.db.adapters.site import SessionSiteRepository + from app.db.adapters.subscription import ( + SessionSubscriptionHistoryRepository, + SessionSubscriptionRepository, + ) + from app.db.oper.mediaserver import MediaServerOper + from app.db.oper.message import MessageOper + from app.db.oper.workflow import WorkflowOper + from app.db.session import async_session_scope, get_async_db, get_db + from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork + from app.startup.composition.subscription import ( + async_rule_group_mutation_scope, + build_subscription_batch_writer, + rule_group_mutation_scope, + site_reference_mutation_scope, + ) + + dependencies = inputs.dependencies + configuration = inputs.configuration + authentication = AuthenticationRuntime( + user_repository=inputs.authentication.user_repository, + passkey_repository=inputs.authentication.passkey_repository, + standalone_user=inputs.authentication.standalone_user, + system_config=inputs.authentication.system_config, + passkey=inputs.authentication.passkey, + ) + host_runtime = HostRuntime( + agent_chat=AgentChatRuntime( + async_session=get_async_db, + repository=inputs.agent.chat_repository, + transaction=SqlAlchemyAsyncUnitOfWork, + persistence=inputs.agent.persistence, + ), + agent=inputs.agent.data, + persistence=PersistenceRuntime( + sync_session=get_db, + async_session=get_async_db, + sync_transaction=SqlAlchemyUnitOfWork, + async_transaction=SqlAlchemyAsyncUnitOfWork, + ), + authentication=authentication, + messaging=MessagingRuntime( + repository=cast(RepositoryFactory, MessageOper), + helper=dependencies.message_helper, + queue=dependencies.message_queue, + ), + history=HistoryRuntime( + download_repository=SessionDownloadHistoryRepository, + transfer_repository=dependencies.transfer_history, + transfer_mutation_repository=SessionTransferHistoryRepository, + media_server_repository=cast(RepositoryFactory, MediaServerOper), + transfer_execution_repository=dependencies.transfer_execution, + ), + site=SiteRuntime( + repository=SessionSiteRepository, + standalone=dependencies.site, + ), + subscription=SubscriptionRuntime( + async_session=get_async_db, + repository=SessionSubscriptionRepository, + history_repository=SessionSubscriptionHistoryRepository, + transaction=SqlAlchemyAsyncUnitOfWork, + outbox=SqlAlchemyAsyncOutboxStager, + dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope), + batch_writer=build_subscription_batch_writer, + rule_group_mutation_scope=partial( + rule_group_mutation_scope, + configuration.system_config.publish_many, + ), + async_rule_group_mutation_scope=partial( + async_rule_group_mutation_scope, + configuration.system_config.publish_many, + ), + site_reference_mutation_scope=partial( + site_reference_mutation_scope, + configuration.system_config.publish_many, + ), + ), + workflow=WorkflowRuntime( + query=inputs.database.workflow_query, + repository=cast(RepositoryFactory, WorkflowOper), + system_config=lambda: configuration.system_service, + ), + configuration=configuration.runtime, + settings=configuration.settings, + tasks=inputs.tasks, + ) + api_data = ApiDataPorts( + sync_session=host_runtime.persistence.sync_session, + async_session=host_runtime.persistence.async_session, + repositories={ + "download_history": host_runtime.history.download_repository, + "media_server": host_runtime.history.media_server_repository, + "message": host_runtime.messaging.repository, + "passkey": host_runtime.authentication.passkey_repository, + "site": host_runtime.site.repository, + "subscribe": host_runtime.subscription.repository, + "subscribe_history": host_runtime.subscription.history_repository, + "user": host_runtime.authentication.user_repository, + "workflow": host_runtime.workflow.repository, + }, + standalone={ + "passkey": host_runtime.authentication.passkey, + "system_config": host_runtime.authentication.system_config, + "user": host_runtime.authentication.standalone_user, + }, + unit_of_work={ + "async": host_runtime.persistence.async_transaction, + "sync": host_runtime.persistence.sync_transaction, + }, + ) + return RuntimeComposition( + runtime=host_runtime, + dependencies=dependencies, + api_data=api_data, + site_query=SiteQueryService(repository=dependencies.site), + site_health=SiteHealthService(repository=dependencies.site), + ) + + +def publish_runtime(composition: RuntimeComposition) -> None: + """发布 HostRuntime 派生的兼容端口与共享领域服务。""" + configure_api_data_runtime(composition.api_data) + configure_transfer_history_repository(lambda: composition.dependencies.transfer_history) + configure_site_query_service(composition.site_query) + configure_site_health_service(composition.site_health) + + +def reset_runtime() -> None: + """撤销当前 lifespan 由运行时组合根发布的全部投影。""" + reset_site_health_service() + reset_site_query_service() + reset_transfer_history_repository() + reset_api_data_runtime() diff --git a/app/startup/composition/security.py b/app/startup/composition/security.py index eb1414ee1..546526e25 100644 --- a/app/startup/composition/security.py +++ b/app/startup/composition/security.py @@ -1,5 +1,8 @@ """认证、用户与 PassKey 服务的启动组合。""" +from dataclasses import dataclass +from typing import cast + from app.adapters.web.security.access import ( reset_superuser_token_payload_provider, set_superuser_token_payload_provider, @@ -24,18 +27,30 @@ from app.db.adapters.user import SqlAlchemyUserRepository from app.db.oper.passkey import PassKeyOper from app.db.oper.systemconfig import SystemConfigOper from app.runtime.cache import TTLCache -from app.startup.composition.context import AuthenticationRuntime +from app.startup.composition.context import ( + RepositoryFactory, + StandaloneRepositoryFactory, +) from app.startup.composition.database import build_transactional_user_repository -def configure_security_services() -> AuthenticationRuntime: +@dataclass(frozen=True, slots=True) +class SecurityComposition: + """保存认证领域运行时投影所需的持久化工厂。""" + + user_repository: RepositoryFactory + passkey_repository: RepositoryFactory + standalone_user: StandaloneRepositoryFactory + system_config: StandaloneRepositoryFactory + passkey: StandaloneRepositoryFactory + + +def configure_security_services() -> SecurityComposition: """构造并登记认证、用户查询和 PassKey 服务。""" configure_user_lookups( by_id=lambda user_id: build_transactional_user_repository().get_by_id(user_id), by_name=lambda username: build_transactional_user_repository().get_by_name(username), - by_channel=lambda **bindings: build_transactional_user_repository().find_name_by_bindings( - bindings - ), + by_channel=lambda **bindings: build_transactional_user_repository().find_name_by_bindings(bindings), ) configure_auth_service( AuthService( @@ -52,8 +67,9 @@ def configure_security_services() -> AuthenticationRuntime: ) ) configure_passkey_service(PasskeyService(repository=PassKeyOper())) - return AuthenticationRuntime( + return SecurityComposition( user_repository=SqlAlchemyUserRepository, + passkey_repository=cast(RepositoryFactory, PassKeyOper), standalone_user=build_transactional_user_repository, system_config=SystemConfigOper, passkey=PassKeyOper, diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index c62dd91e6..7dccc97d4 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -2,7 +2,6 @@ import asyncio import inspect import sys from collections.abc import Awaitable -from functools import partial from typing import Callable, cast from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper @@ -28,7 +27,6 @@ from app.application.configuration import ( configure_transfer_retry_config, get_configured_system_config, ) -from app.application.history import configure_transfer_history_repository from app.application.messaging.agent import ( dispatch_web_agent_message_event, shutdown_web_agent_background_tasks, @@ -39,55 +37,20 @@ from app.application.messaging.chat import ( ) from app.application.messaging.message import ( MessageHelper, - MessageQueueManager, stop_message, ) from app.application.module import configure_module_runtime from app.application.outbox import configure_outbox_dispatcher from app.application.security.url import close_image_proxy_block_log_coalescer from app.application.service import configure_service_directory -from app.application.site.health import SiteHealthService, configure_site_health_service -from app.application.site.query import SiteQueryService, configure_site_query_service from app.application.workflow import configure_workflow_execution from app.command import CommandChain -from app.db.adapters.history.download import ( - SessionDownloadHistoryRepository, - TransactionalDownloadHistoryRepository, -) -from app.db.adapters.history.transfer import ( - SessionTransferHistoryRepository, - TransactionalTransferHistoryRepository, -) -from app.db.adapters.outbox import ( - SqlAlchemyAsyncOutboxDispatchStore, - SqlAlchemyAsyncOutboxStager, -) -from app.db.adapters.site import SessionSiteRepository, TransactionalSiteRepository -from app.db.adapters.subscription import ( - SessionSubscriptionHistoryRepository, - SessionSubscriptionRepository, - TransactionalSubscriptionHistoryRepository, - TransactionalSubscriptionRepository, -) -from app.db.adapters.transfer.execution import ( - TransactionalTransferExecutionRepository, -) from app.db.adapters.workflow import ( TransactionalWorkflowExecutionService, ) -from app.db.oper.mediaserver import MediaServerOper -from app.db.oper.message import MessageOper -from app.db.oper.workflow import WorkflowOper from app.db.session import ( SessionFactory, - async_session_scope, close_database, - get_async_db, - get_db, -) -from app.db.uow import ( - SqlAlchemyAsyncUnitOfWork, - SqlAlchemyUnitOfWork, ) from app.runtime.config import settings as legacy_settings from app.runtime.events import EventHandlerBinding, EventManager @@ -119,15 +82,7 @@ from app.startup.composition.configuration import ( publish_configuration, reset_configuration, ) -from app.startup.composition.context import ( - HistoryRuntime, - HostRuntime, - MessagingRuntime, - PersistenceRuntime, - SiteRuntime, - SubscriptionRuntime, - WorkflowRuntime, -) +from app.startup.composition.context import HostRuntime from app.startup.composition.database import ( compose_database_services, configure_database, @@ -139,17 +94,17 @@ from app.startup.composition.database import ( ) from app.startup.composition.network import configure_application_network_ports from app.startup.composition.outbox import build_outbox_dispatcher +from app.startup.composition.runtime import ( + RuntimeInputs, + compose_runtime, + compose_runtime_dependencies, + publish_runtime, +) from app.startup.composition.security import ( configure_security_access, configure_security_services, ) from app.startup.composition.server import configure_server_services -from app.startup.composition.subscription import ( - async_rule_group_mutation_scope, - build_subscription_batch_writer, - rule_group_mutation_scope, - site_reference_mutation_scope, -) from app.startup.initializers.agent import configure_agent_data_context, init_agent from app.startup.initializers.resources import ( init_managed_resources, @@ -457,98 +412,28 @@ async def _initialize_modules() -> HostRuntime: ) system_config = configuration.system_config workflow_query = database_services.workflow_query - download_history_repository = TransactionalDownloadHistoryRepository( - sync_session=SessionFactory, - async_session=async_session_scope, - ) - transfer_history_repository = TransactionalTransferHistoryRepository( - sync_session=SessionFactory, - async_session=async_session_scope, - ) - site_repository = TransactionalSiteRepository( - sync_session=SessionFactory, - async_session=async_session_scope, - ) - subscription_repository = TransactionalSubscriptionRepository( - sync_session=SessionFactory, - async_session=async_session_scope, - ) - subscription_history_repository = TransactionalSubscriptionHistoryRepository( - async_session=async_session_scope, - ) - transfer_execution_repository = TransactionalTransferExecutionRepository(SessionFactory) - message_helper = MessageHelper() - message_queue = MessageQueueManager(auto_start=False) + runtime_dependencies = compose_runtime_dependencies() agent_composition = compose_agent( runtime=database_runtime, system_config=system_config, - site=site_repository, - subscription=subscription_repository, - subscription_history=subscription_history_repository, - transfer_history=transfer_history_repository, - transfer_execution=transfer_execution_repository, - download_history=download_history_repository, + dependencies=runtime_dependencies, ) - authentication = configure_security_services() - host_runtime = HostRuntime( - agent_chat=agent_composition.chat, - agent=agent_composition.data, - persistence=PersistenceRuntime( - sync_session=get_db, - async_session=get_async_db, - sync_transaction=SqlAlchemyUnitOfWork, - async_transaction=SqlAlchemyAsyncUnitOfWork, - ), - authentication=authentication, - messaging=MessagingRuntime( - repository=MessageOper, - helper=message_helper, - queue=message_queue, - ), - history=HistoryRuntime( - download_repository=SessionDownloadHistoryRepository, - transfer_repository=transfer_history_repository, - transfer_mutation_repository=SessionTransferHistoryRepository, - media_server_repository=MediaServerOper, - transfer_execution_repository=transfer_execution_repository, - ), - site=SiteRuntime( - repository=SessionSiteRepository, - standalone=site_repository, - ), - subscription=SubscriptionRuntime( - async_session=get_async_db, - repository=SessionSubscriptionRepository, - history_repository=SessionSubscriptionHistoryRepository, - transaction=SqlAlchemyAsyncUnitOfWork, - outbox=SqlAlchemyAsyncOutboxStager, - dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope), - batch_writer=build_subscription_batch_writer, - rule_group_mutation_scope=partial( - rule_group_mutation_scope, - system_config.publish_many, - ), - async_rule_group_mutation_scope=partial( - async_rule_group_mutation_scope, - system_config.publish_many, - ), - site_reference_mutation_scope=partial( - site_reference_mutation_scope, - system_config.publish_many, - ), - ), - workflow=WorkflowRuntime( - query=workflow_query, - repository=WorkflowOper, - system_config=get_configured_system_config, - ), - configuration=configuration.runtime, - settings=configuration.settings, - tasks=get_task_registry(), + security_composition = configure_security_services() + runtime_composition = compose_runtime( + RuntimeInputs( + configuration=configuration, + database=database_services, + agent=agent_composition, + authentication=security_composition, + dependencies=runtime_dependencies, + tasks=get_task_registry(), + ) ) + host_runtime = runtime_composition.runtime publish_database_services(database_services) + publish_runtime(runtime_composition) configure_runtime_data_providers() - configure_server_services(workflow_query, subscription_repository) + configure_server_services(workflow_query, runtime_dependencies.subscription) workflow_execution = TransactionalWorkflowExecutionService(SessionFactory) configure_workflow_execution(workflow_execution) configure_outbox_dispatcher(build_outbox_dispatcher) @@ -562,9 +447,6 @@ async def _initialize_modules() -> HostRuntime: agent_composition, data_context_registrar=configure_agent_data_context, ) - configure_transfer_history_repository(lambda: transfer_history_repository) - configure_site_query_service(SiteQueryService(repository=site_repository)) - configure_site_health_service(SiteHealthService(repository=site_repository)) from app.agent.tools.manager import moviepilot_tool_manager moviepilot_tool_manager.set_data_context(agent_composition.data) @@ -575,14 +457,8 @@ async def _initialize_modules() -> HostRuntime: configure_wallpaper_services() # Chain 无参兼容入口由组合根明确提供依赖上下文;测试和新代码可直接注入替代上下文。 configure_chain_runtime_context( - message_helper=message_helper, - message_queue=message_queue, + dependencies=runtime_dependencies, system_config=system_config, - site=site_repository, - subscription=subscription_repository, - download_history=download_history_repository, - transfer_history=transfer_history_repository, - transfer_execution=transfer_execution_repository, configuration=configuration.runtime.chain, ) # 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。 diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index d012555cd..279115196 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -62,7 +62,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | `chain` | 多入口复用的用例编排与 Module 动态分发已稳定,包根 SCC 已清零 | God object、私有长方法、无参构造和资源物化过重 | | `db/oper` | Model/Oper 已不自建 Session、不自行提交 | 无 Session Facade 仍被宿主组合根注入,业务操作可能拆成多个事务 | | `db/adapters` | 已有显式 Session/UoW 的参考切片 | Port 返回类型不够稳定;Outbox 的 stage 与自提交 dispatcher store 混在一个类型 | -| `startup` | 已是 HostRuntime 和生命周期组合根;Chain 兼容上下文由 `composition/chain.py` 独立装配 | `initializers/modules.py` 仍有其他领域高扇出和具体对象目录装配 | +| `startup` | `composition/runtime.py` 已成为 HostRuntime、领域 Runtime 与旧 ApiData 投影唯一 owner;Chain/Agent 复用同一 RuntimeDependencies | `initializers/modules.py` 仍保留启动顺序与跨领域发布调用,高扇出需由生命周期验收继续约束 | | API/Command/Scheduler | 多数入口已转向 Chain/Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 | | `sdk`/`compat` | 精确映射、稳定插件 ABI 和宿主 canonical 路径已落地 | SDK 仍暴露部分可变全局对象/具体 Manager,只能渐进收窄,不能直接删除 | @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 971 / 8,306 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 972 / 8,301 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 | | 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 10,335 / 593 文件 | 新 Chain composition owner 零类型债,`modules.py` 低水位下降 1;strict frontier 当前覆盖 41 个文件 | -| Ruff 历史诊断 | 648 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 全量 mypy 历史债务 | 10,320 / 592 文件 | Runtime 唯一组合 owner 零新增类型债,`modules.py` 低水位下降 4;strict frontier 当前覆盖 41 个文件 | +| Ruff 历史诊断 | 644 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 @@ -675,8 +675,9 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 - [x] L4.2 已将 network、security、agent、server、outbox 与 Chain 装配迁入单词型 `startup/composition/*` owner;Agent composition 在 Worker 启动后读取容量并共享同一数据和任务对象, Chain 通过 provider 保持插件无参构造兼容,旧 Transfer command 只在真实调用时延迟导入。 -- [ ] 继续将 HostRuntime 剩余构造移到 `startup/composition/runtime.py`,最终让 initializer - 只负责顺序、注册和是否重启的决策。 +- [x] L4.3 已将 HostRuntime、命名领域 Runtime 和旧 `ApiDataPorts` 投影统一移到单词型 + `startup/composition/runtime.py`;`RuntimeDependencies` 由 Agent、Chain、HostRuntime 复用, + `HostRuntime.tasks` 显式绑定 lifecycle 的 TaskRegistry,Chain 不再二次构造 transfer execution。 - [ ] 保留生命周期 manifest 和顺序快照;高扇出在组合根是允许的,但业务实现不能继续沉积其中。 ### ARCH-204 收敛 sync/async 重复与测试存量 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 16b2463a6..19023dc52 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -284,13 +284,18 @@ sequenceDiagram 从 `HostRuntime.configuration` 获取 frozen 配置快照。系统设置管理 API 通过 `HostRuntime.settings` 的窄服务读写可变部署设置,业务域不接触 Settings 实例;生产与测试组合根统一 复用 `startup/composition/configuration.py` 的映射、快照加载与发布。`startup/composition/database.py` - 持有数据库 worker、兼容事务 runner、查询/插件持久化和旧 `ApiDataPorts` 的具体装配;initializer - 只保留顺序调用。`startup/composition/chain.py` 集中构造无参 Chain 兼容上下文及其持久化、事件、 + 持有数据库 worker、兼容事务 runner、查询与插件持久化;`startup/composition/runtime.py` 是 + `HostRuntime`、全部命名领域 Runtime 和旧 `ApiDataPorts` 投影的唯一构造/发布 owner。它先构造 + 一份 frozen `RuntimeDependencies`,Agent、Chain 与 HostRuntime 复用同一仓储、transfer execution + ledger 和消息对象,导入阶段不启动数据库 worker 或消息队列;initializer 只保留顺序调用。 + `startup/composition/chain.py` 集中构造无参 Chain 兼容上下文及其持久化、事件、 Outbox、消息和模块分发依赖,并延迟绑定旧 Transfer command 与壁纸 Chain,避免组合根导入环。 `startup/composition/security.py` 统一装配认证、用户查询、PassKey 与 Web 访问 - provider,并将同一 `AuthenticationRuntime` 交给 HostRuntime;`startup/composition/network.py` 统一 - 装配网络应用端口;`startup/composition/agent.py` 构造并发布唯一的 Agent 数据、会话和自主任务对象, - `HostRuntime`、工具管理器与 Scheduler 复用同一对象身份。`ApiDataPorts` 仅保留旧导入 ABI, + provider,并把持久化工厂交给 runtime owner 投影为 `AuthenticationRuntime`; + `startup/composition/network.py` 统一装配网络应用端口;`startup/composition/agent.py` 构造并发布 + 唯一的 Agent 数据、会话持久化和自主任务对象,由 runtime owner 投影 `AgentChatRuntime`, + `HostRuntime`、工具管理器与 Scheduler 复用同一对象身份。`ApiDataPorts` 严格从同一 HostRuntime + 字段投影,仅保留旧导入 ABI, 不参与正式请求链路。 - **安全模式**:`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。 - **Scheduler 同名职责包**:旧 `app/scheduler.py` 单体已退役;`catalog.py` 负责作业目录和计划投影, @@ -749,8 +754,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 971 | -| 内部导入边 | 8,306 | +| Python 模块 | 972 | +| 内部导入边 | 8,301 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 206e82992..193f8db90 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -139,15 +139,22 @@ not startup. Lower-level runtime modules must not import startup. `startup/composition/configuration.py` owns configuration snapshot loading, typed runtime/settings construction and publication. `startup/composition/database.py` owns the process database worker, compatibility transaction runner, query services, -plugin persistence and legacy API data-port wiring. Initializers call these owners in +plugin persistence and workflow query wiring. Initializers call these owners in startup order and must not recreate their concrete construction. +`startup/composition/runtime.py` is the sole constructor of `HostRuntime`, its named +domain Runtime projections and the legacy `ApiDataPorts` projection. It first creates +one frozen `RuntimeDependencies` aggregate; Agent, Chain and HostRuntime must reuse the +same repositories, transfer-execution ledger and message owners rather than constructing +parallel instances. Importing this owner remains cold: concrete DB adapters are resolved +only when composition functions run, and FastAPI `app.state` publication remains owned by +the lifecycle layer. `startup/composition/chain.py` owns the no-argument Chain compatibility context, typed persistence/dispatch object graph, lazy legacy Transfer command binding and -wallpaper-provider publication. `initializers/modules.py` only passes the already -constructed repositories in startup order and must not recreate those dependencies. +wallpaper-provider publication. `initializers/modules.py` passes the already constructed +`RuntimeDependencies` in startup order and must not recreate those dependencies. `startup/composition/security.py` owns authentication, user lookup, PassKey and Web -access provider assembly; `initializers/modules.py` only invokes that owner and places -its returned `AuthenticationRuntime` into the host runtime. +access provider assembly; it returns the persistence factories needed by the runtime +owner, which alone constructs `AuthenticationRuntime`. `startup/composition/network.py` owns concrete network-test, image, internal-address and message-ingress Adapter wiring while retaining lazy RuntimeSettings reads. Initializers call these owners in @@ -160,8 +167,9 @@ startup order and must not recreate its concrete construction. construction and registration, including lazy local-data readers and external transport callbacks; `initializers/modules.py` retains only its startup-order call and the later explicit network warmup. Database runtime -`startup/composition/agent.py` owns the single `AgentDataContext`, `AgentChatRuntime`, -chat persistence and autonomous-task repository/execution service set. It may accept +`startup/composition/agent.py` owns the single `AgentDataContext`, chat persistence and +autonomous-task repository/execution service set. The runtime owner projects those same +objects into `AgentChatRuntime`. Agent composition may accept the Agent initializer's data-context registrar as a callback, but must not import `startup.initializers`; tool-manager, scheduler and Agent activation order remains in the initializer. Database runtime ownership rejects overlapping lifespans; after a diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 35d117e8c..37c95f3bc 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8306, - "edge_sha256": "2224fccf31c611f1ba2cf9e8afbdd33b9277ac2846101c85d57e0801b2a7b542", + "edge_count": 8301, + "edge_sha256": "a1148b9576004423eb291429dc3e9a0e99282f14fe9d3d416539584a28aec8d0", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8629,15 +8629,8 @@ "app.startup.composition.agent -> app.application", "app.startup.composition.agent -> app.application.agent", "app.startup.composition.agent -> app.application.agenttask", - "app.startup.composition.agent -> app.application.history", "app.startup.composition.agent -> app.application.messaging", "app.startup.composition.agent -> app.application.messaging.chat", - "app.startup.composition.agent -> app.application.site", - "app.startup.composition.agent -> app.application.site.contract", - "app.startup.composition.agent -> app.application.subscription", - "app.startup.composition.agent -> app.application.subscription.contract", - "app.startup.composition.agent -> app.application.transfer", - "app.startup.composition.agent -> app.application.transfer.execution", "app.startup.composition.agent -> app.db", "app.startup.composition.agent -> app.db.adapters", "app.startup.composition.agent -> app.db.adapters.agent", @@ -8645,21 +8638,17 @@ "app.startup.composition.agent -> app.db.oper.agentchat", "app.startup.composition.agent -> app.db.oper.systemconfig", "app.startup.composition.agent -> app.db.session", - "app.startup.composition.agent -> app.db.uow", "app.startup.composition.agent -> app.startup", "app.startup.composition.agent -> app.startup.composition", "app.startup.composition.agent -> app.startup.composition.context", "app.startup.composition.agent -> app.startup.composition.database", + "app.startup.composition.agent -> app.startup.composition.runtime", "app.startup.composition.agent -> app.startup.composition.subscription", "app.startup.composition.chain -> app.application", "app.startup.composition.chain -> app.application.chain", "app.startup.composition.chain -> app.application.chain.context", "app.startup.composition.chain -> app.application.configuration", "app.startup.composition.chain -> app.application.image", - "app.startup.composition.chain -> app.application.messaging", - "app.startup.composition.chain -> app.application.messaging.message", - "app.startup.composition.chain -> app.application.transfer", - "app.startup.composition.chain -> app.application.transfer.execution", "app.startup.composition.chain -> app.chain", "app.startup.composition.chain -> app.chain.mediaserver", "app.startup.composition.chain -> app.chain.tmdb", @@ -8669,12 +8658,7 @@ "app.startup.composition.chain -> app.db.adapters", "app.startup.composition.chain -> app.db.adapters.chain", "app.startup.composition.chain -> app.db.adapters.download", - "app.startup.composition.chain -> app.db.adapters.history", - "app.startup.composition.chain -> app.db.adapters.history.download", - "app.startup.composition.chain -> app.db.adapters.history.transfer", "app.startup.composition.chain -> app.db.adapters.mediaserver", - "app.startup.composition.chain -> app.db.adapters.site", - "app.startup.composition.chain -> app.db.adapters.subscription", "app.startup.composition.chain -> app.db.adapters.transfer", "app.startup.composition.chain -> app.db.adapters.transfer.admission", "app.startup.composition.chain -> app.db.oper", @@ -8693,6 +8677,7 @@ "app.startup.composition.chain -> app.startup", "app.startup.composition.chain -> app.startup.composition", "app.startup.composition.chain -> app.startup.composition.database", + "app.startup.composition.chain -> app.startup.composition.runtime", "app.startup.composition.chain -> app.startup.composition.subscription", "app.startup.composition.configuration -> app.application", "app.startup.composition.configuration -> app.application.configuration", @@ -8734,8 +8719,6 @@ "app.startup.composition.database -> app.adapters.system.backup", "app.startup.composition.database -> app.adapters.system.backup.database", "app.startup.composition.database -> app.adapters.system.backup.files", - "app.startup.composition.database -> app.api", - "app.startup.composition.database -> app.api.data", "app.startup.composition.database -> app.application", "app.startup.composition.database -> app.application.backup", "app.startup.composition.database -> app.application.database", @@ -8746,13 +8729,9 @@ "app.startup.composition.database -> app.application.workflow", "app.startup.composition.database -> app.db", "app.startup.composition.database -> app.db.adapters", - "app.startup.composition.database -> app.db.adapters.history", - "app.startup.composition.database -> app.db.adapters.history.download", "app.startup.composition.database -> app.db.adapters.pluginidentity", "app.startup.composition.database -> app.db.adapters.plugininstallation", "app.startup.composition.database -> app.db.adapters.query", - "app.startup.composition.database -> app.db.adapters.site", - "app.startup.composition.database -> app.db.adapters.subscription", "app.startup.composition.database -> app.db.adapters.transaction", "app.startup.composition.database -> app.db.adapters.user", "app.startup.composition.database -> app.db.adapters.workflow", @@ -8760,11 +8739,7 @@ "app.startup.composition.database -> app.db.health", "app.startup.composition.database -> app.db.maintenance", "app.startup.composition.database -> app.db.oper", - "app.startup.composition.database -> app.db.oper.mediaserver", - "app.startup.composition.database -> app.db.oper.message", - "app.startup.composition.database -> app.db.oper.passkey", "app.startup.composition.database -> app.db.oper.systemconfig", - "app.startup.composition.database -> app.db.oper.workflow", "app.startup.composition.database -> app.db.session", "app.startup.composition.database -> app.db.uow", "app.startup.composition.database -> app.db.worker", @@ -8800,6 +8775,42 @@ "app.startup.composition.outbox -> app.schemas", "app.startup.composition.outbox -> app.schemas.message", "app.startup.composition.outbox -> app.schemas.types", + "app.startup.composition.runtime -> app.api", + "app.startup.composition.runtime -> app.api.data", + "app.startup.composition.runtime -> app.application", + "app.startup.composition.runtime -> app.application.history", + "app.startup.composition.runtime -> app.application.messaging", + "app.startup.composition.runtime -> app.application.messaging.message", + "app.startup.composition.runtime -> app.application.site", + "app.startup.composition.runtime -> app.application.site.contract", + "app.startup.composition.runtime -> app.application.site.health", + "app.startup.composition.runtime -> app.application.site.query", + "app.startup.composition.runtime -> app.application.subscription", + "app.startup.composition.runtime -> app.application.subscription.contract", + "app.startup.composition.runtime -> app.application.transfer", + "app.startup.composition.runtime -> app.application.transfer.execution", + "app.startup.composition.runtime -> app.db", + "app.startup.composition.runtime -> app.db.adapters", + "app.startup.composition.runtime -> app.db.adapters.history", + "app.startup.composition.runtime -> app.db.adapters.history.download", + "app.startup.composition.runtime -> app.db.adapters.history.transfer", + "app.startup.composition.runtime -> app.db.adapters.outbox", + "app.startup.composition.runtime -> app.db.adapters.site", + "app.startup.composition.runtime -> app.db.adapters.subscription", + "app.startup.composition.runtime -> app.db.adapters.transfer", + "app.startup.composition.runtime -> app.db.adapters.transfer.execution", + "app.startup.composition.runtime -> app.db.oper", + "app.startup.composition.runtime -> app.db.oper.mediaserver", + "app.startup.composition.runtime -> app.db.oper.message", + "app.startup.composition.runtime -> app.db.oper.workflow", + "app.startup.composition.runtime -> app.db.session", + "app.startup.composition.runtime -> app.db.uow", + "app.startup.composition.runtime -> app.runtime", + "app.startup.composition.runtime -> app.runtime.tasks", + "app.startup.composition.runtime -> app.startup", + "app.startup.composition.runtime -> app.startup.composition", + "app.startup.composition.runtime -> app.startup.composition.context", + "app.startup.composition.runtime -> app.startup.composition.subscription", "app.startup.composition.security -> app.adapters", "app.startup.composition.security -> app.adapters.web", "app.startup.composition.security -> app.adapters.web.security", @@ -8965,7 +8976,6 @@ "app.startup.initializers.modules -> app.agent.tools.manager", "app.startup.initializers.modules -> app.application", "app.startup.initializers.modules -> app.application.configuration", - "app.startup.initializers.modules -> app.application.history", "app.startup.initializers.modules -> app.application.messaging", "app.startup.initializers.modules -> app.application.messaging.agent", "app.startup.initializers.modules -> app.application.messaging.chat", @@ -8976,8 +8986,6 @@ "app.startup.initializers.modules -> app.application.security.url", "app.startup.initializers.modules -> app.application.service", "app.startup.initializers.modules -> app.application.site", - "app.startup.initializers.modules -> app.application.site.health", - "app.startup.initializers.modules -> app.application.site.query", "app.startup.initializers.modules -> app.application.workflow", "app.startup.initializers.modules -> app.chain", "app.startup.initializers.modules -> app.chain.download", @@ -8990,21 +8998,8 @@ "app.startup.initializers.modules -> app.command", "app.startup.initializers.modules -> app.db", "app.startup.initializers.modules -> app.db.adapters", - "app.startup.initializers.modules -> app.db.adapters.history", - "app.startup.initializers.modules -> app.db.adapters.history.download", - "app.startup.initializers.modules -> app.db.adapters.history.transfer", - "app.startup.initializers.modules -> app.db.adapters.outbox", - "app.startup.initializers.modules -> app.db.adapters.site", - "app.startup.initializers.modules -> app.db.adapters.subscription", - "app.startup.initializers.modules -> app.db.adapters.transfer", - "app.startup.initializers.modules -> app.db.adapters.transfer.execution", "app.startup.initializers.modules -> app.db.adapters.workflow", - "app.startup.initializers.modules -> app.db.oper", - "app.startup.initializers.modules -> app.db.oper.mediaserver", - "app.startup.initializers.modules -> app.db.oper.message", - "app.startup.initializers.modules -> app.db.oper.workflow", "app.startup.initializers.modules -> app.db.session", - "app.startup.initializers.modules -> app.db.uow", "app.startup.initializers.modules -> app.runtime", "app.startup.initializers.modules -> app.runtime.config", "app.startup.initializers.modules -> app.runtime.events", @@ -9031,9 +9026,9 @@ "app.startup.initializers.modules -> app.startup.composition.database", "app.startup.initializers.modules -> app.startup.composition.network", "app.startup.initializers.modules -> app.startup.composition.outbox", + "app.startup.initializers.modules -> app.startup.composition.runtime", "app.startup.initializers.modules -> app.startup.composition.security", "app.startup.initializers.modules -> app.startup.composition.server", - "app.startup.initializers.modules -> app.startup.composition.subscription", "app.startup.initializers.modules -> app.startup.initializers", "app.startup.initializers.modules -> app.startup.initializers.agent", "app.startup.initializers.modules -> app.startup.initializers.resources", @@ -9399,7 +9394,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 971, + "module_count": 972, "modules": [ "app", "app.adapters", @@ -10329,6 +10324,7 @@ "app.startup.composition.database", "app.startup.composition.network", "app.startup.composition.outbox", + "app.startup.composition.runtime", "app.startup.composition.security", "app.startup.composition.server", "app.startup.composition.subscription", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 27e9e046b..c49c98d01 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -3276,12 +3276,12 @@ "no-untyped-def": 2 }, "app/startup/initializers/modules.py": { - "arg-type": 5, + "arg-type": 2, "assignment": 1, "attr-defined": 2, "misc": 1, "no-any-return": 2, - "no-untyped-call": 15, + "no-untyped-call": 14, "no-untyped-def": 6, "return-value": 2 }, diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index c12cdff14..0b614b5eb 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 497, + "loaded_app_module_count": 491, "max_ms": 1159.462, "median_ms": 1074.592, "min_ms": 1072.211, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 509, + "loaded_app_module_count": 503, "max_ms": 1094.111, "median_ms": 1092.394, "min_ms": 1090.171, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 511, + "loaded_app_module_count": 505, "max_ms": 1165.991, "median_ms": 1159.713, "min_ms": 1143.531, diff --git a/tests/test_agent_composition.py b/tests/test_agent_composition.py index 3c2dc649b..09f53c15c 100644 --- a/tests/test_agent_composition.py +++ b/tests/test_agent_composition.py @@ -8,6 +8,7 @@ import sys from types import SimpleNamespace from app.startup.composition import agent as agent_composition +from app.startup.composition.runtime import RuntimeDependencies class _StartedWorker: @@ -34,15 +35,20 @@ def _compose() -> tuple[agent_composition.AgentComposition, _StartedWorker]: transaction=SimpleNamespace(sync=lambda operation: operation(object())), ) system_config = SimpleNamespace(publish_many=lambda _values: None) - composition = agent_composition.compose_agent( - runtime=runtime, - system_config=system_config, + dependencies = RuntimeDependencies( + download_history=object(), + transfer_history=object(), site=object(), subscription=object(), subscription_history=object(), - transfer_history=object(), transfer_execution=object(), - download_history=object(), + message_helper=object(), + message_queue=object(), + ) + composition = agent_composition.compose_agent( + runtime=runtime, + system_config=system_config, + dependencies=dependencies, ) return composition, worker @@ -52,7 +58,7 @@ def test_agent_composition_reuses_shared_objects_and_started_worker() -> None: composition, worker = _compose() assert composition.data.tasks is composition.tasks - assert composition.chat.persistence is composition.data.chat_persistence + assert composition.persistence is composition.data.chat_persistence assert composition.data.chat_persistence._async_executor is worker assert composition.data.chat_persistence._capacity == 17 assert composition.execution._async_executor is worker diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 2d9ef0f1f..109975150 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -296,24 +296,16 @@ def test_agent_manager_uses_focused_owner_modules_and_precise_facade(): manager_tree = ast.parse(owner_paths["manager"].read_text(encoding="utf-8-sig")) manager_class = next( - node - for node in manager_tree.body - if isinstance(node, ast.ClassDef) and node.name == "AgentManager" + node for node in manager_tree.body if isinstance(node, ast.ClassDef) and node.name == "AgentManager" ) manager_methods = { - node.name - for node in manager_class.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + node.name for node in manager_class.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } assert manager_methods == {"__init__"} assert [ast.unparse(base) for base in manager_class.bases] == ["AgentTaskOwner"] - orchestrator_tree = ast.parse( - owner_paths["orchestrator"].read_text(encoding="utf-8-sig") - ) - orchestrator_classes = { - node.name for node in orchestrator_tree.body if isinstance(node, ast.ClassDef) - } + orchestrator_tree = ast.parse(owner_paths["orchestrator"].read_text(encoding="utf-8-sig")) + orchestrator_classes = {node.name for node in orchestrator_tree.body if isinstance(node, ast.ClassDef)} orchestrator_assignments = { target.id for node in orchestrator_tree.body @@ -325,9 +317,7 @@ def test_agent_manager_uses_focused_owner_modules_and_precise_facade(): assert "_MessageTask" not in orchestrator_classes assert "agent_manager" not in orchestrator_assignments - startup_source = ( - APP_ROOT / "startup" / "initializers" / "agent.py" - ).read_text(encoding="utf-8-sig") + startup_source = (APP_ROOT / "startup" / "initializers" / "agent.py").read_text(encoding="utf-8-sig") assert "from app.agent.manager import AgentManager" in startup_source assert "from app.agent.orchestrator import AgentManager" not in startup_source @@ -390,14 +380,10 @@ def test_domain_media_projection_uses_single_word_owner_package() -> None: filename=str(context_path), ) media_class = next( - node - for node in context_tree.body - if isinstance(node, ast.ClassDef) and node.name == "MediaInfo" + node for node in context_tree.body if isinstance(node, ast.ClassDef) and node.name == "MediaInfo" ) methods = { - node.name: node - for node in media_class.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + node.name: node for node in media_class.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } for name in ( "set_tmdb_info", @@ -419,9 +405,7 @@ def test_domain_media_projection_uses_single_word_owner_package() -> None: assert isinstance(executable[0], ast.Expr) assert isinstance(executable[0].value, ast.Call) - schema_source = (APP_ROOT / "schemas" / "context.py").read_text( - encoding="utf-8-sig" - ) + schema_source = (APP_ROOT / "schemas" / "context.py").read_text(encoding="utf-8-sig") assert "app.domain.projection" not in schema_source assert not any(name in schema_source for name in methods if name.startswith("set_") and name.endswith("_info")) @@ -697,8 +681,11 @@ def test_download_history_ports_are_typed_detached_and_canonically_injected(): assert "DownloadFiles = Any" not in source startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") - assert "TransactionalDownloadHistoryRepository(" in startup_source - assert "SessionDownloadHistoryRepository" in startup_source + runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") + assert "TransactionalDownloadHistoryRepository(" not in startup_source + assert "TransactionalDownloadHistoryRepository(" in runtime_source + assert "SessionDownloadHistoryRepository" not in startup_source + assert "SessionDownloadHistoryRepository" in runtime_source assert "DownloadHistoryOper" not in startup_source adapter_source = (APP_ROOT / "db" / "adapters" / "history" / "download.py").read_text(encoding="utf-8") @@ -734,9 +721,7 @@ def test_user_configuration_uses_typed_transactional_adapter(): } assert "Any" not in application_source - composition_source = ( - APP_ROOT / "startup" / "composition" / "configuration.py" - ).read_text(encoding="utf-8") + composition_source = (APP_ROOT / "startup" / "composition" / "configuration.py").read_text(encoding="utf-8") assert "TransactionalUserConfigurationRepository(SessionFactory)" in composition_source assert "UserConfigOper" not in composition_source @@ -754,15 +739,10 @@ def test_user_configuration_uses_typed_transactional_adapter(): def test_modules_initializer_delegates_configuration_and_database_composition(): """模块初始化器只编排配置与数据库组合 API,不再内联其构造细节。""" - initializer_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") - configuration_source = ( - APP_ROOT / "startup" / "composition" / "configuration.py" - ).read_text(encoding="utf-8") - database_source = ( - APP_ROOT / "startup" / "composition" / "database.py" - ).read_text(encoding="utf-8") + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") + configuration_source = (APP_ROOT / "startup" / "composition" / "configuration.py").read_text(encoding="utf-8") + database_source = (APP_ROOT / "startup" / "composition" / "database.py").read_text(encoding="utf-8") + runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") for call in ( "start_database_runtime()", @@ -796,17 +776,14 @@ def test_modules_initializer_delegates_configuration_and_database_composition(): assert "SqlAlchemyDataQueryAdapter(" in database_source assert "reset_transaction_runners()" in database_source assert "configure_api_data_runtime" not in initializer_source - assert "configure_api_data_runtime" in database_source + assert "configure_api_data_runtime" not in database_source + assert "configure_api_data_runtime" in runtime_source def test_modules_initializer_delegates_network_composition(): """模块初始化器只调用网络组合 API,不再内联具体 Adapter 组装。""" - initializer_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") - network_source = ( - APP_ROOT / "startup" / "composition" / "network.py" - ).read_text(encoding="utf-8") + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") + network_source = (APP_ROOT / "startup" / "composition" / "network.py").read_text(encoding="utf-8") assert "configure_application_network_ports()" in initializer_source for detail in ( @@ -824,15 +801,9 @@ def test_modules_initializer_delegates_network_composition(): def test_modules_initializer_delegates_outbox_composition(): """模块初始化器只发布 Outbox dispatcher,不再内联 handler 与存储装配。""" - initializer_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") - composition_source = ( - APP_ROOT / "startup" / "composition" / "outbox.py" - ).read_text(encoding="utf-8") - package_source = ( - APP_ROOT / "startup" / "composition" / "__init__.py" - ).read_text(encoding="utf-8") + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") + composition_source = (APP_ROOT / "startup" / "composition" / "outbox.py").read_text(encoding="utf-8") + package_source = (APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8") assert "configure_outbox_dispatcher(build_outbox_dispatcher)" in initializer_source for retired_name in ("_build_outbox_handlers", "_build_outbox_dispatcher"): @@ -850,31 +821,17 @@ def test_modules_initializer_delegates_outbox_composition(): def test_modules_initializer_delegates_server_service_composition(): """中心服务对象图只由单词型 composition owner 构造,initializer 仅保留调用顺序。""" - initializer_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") composition_path = APP_ROOT / "startup" / "composition" / "server.py" composition_source = composition_path.read_text(encoding="utf-8") - architecture_source = ( - PROJECT_ROOT / "docs" / "rules" / "05-architecture.md" - ).read_text(encoding="utf-8") - package_tree = ast.parse( - (APP_ROOT / "startup" / "composition" / "__init__.py").read_text( - encoding="utf-8" - ) - ) + architecture_source = (PROJECT_ROOT / "docs" / "rules" / "05-architecture.md").read_text(encoding="utf-8") + package_tree = ast.parse((APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8")) assert composition_path.is_file() assert not list(composition_path.parent.glob("server_*.py")) - assert "from app.startup.composition.server import configure_server_services" in ( - initializer_source - ) - assert "configure_server_services(workflow_query, subscription_repository)" in ( - initializer_source - ) - assert "`startup/composition/server.py` owns MoviePilot Server" in ( - architecture_source - ) + assert "from app.startup.composition.server import configure_server_services" in (initializer_source) + assert "configure_server_services(workflow_query, runtime_dependencies.subscription)" in initializer_source + assert "`startup/composition/server.py` owns MoviePilot Server" in (architecture_source) for detail in ( "ServerReportService(", "ServerSharingService(", @@ -883,28 +840,21 @@ def test_modules_initializer_delegates_server_service_composition(): assert detail not in initializer_source assert detail in composition_source assert not any( - isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) - for node in package_tree.body + isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) for node in package_tree.body ) def test_modules_initializer_delegates_agent_composition(): """模块初始化器只编排 Agent composition,不再内联数据与任务构造。""" - initializer_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") - agent_source = ( - APP_ROOT / "startup" / "composition" / "agent.py" - ).read_text(encoding="utf-8") + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") + agent_source = (APP_ROOT / "startup" / "composition" / "agent.py").read_text(encoding="utf-8") + runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") assert "compose_agent(" in initializer_source assert "publish_agent_services(" in initializer_source - assert initializer_source.index("await start_database_runtime()") < initializer_source.index( - "compose_agent(" - ) + assert initializer_source.index("await start_database_runtime()") < initializer_source.index("compose_agent(") for detail in ( "AgentDataContext(", - "AgentChatRuntime(", "AgentChatPersistenceService(", "AgentChatService(", "TransactionalAgentTaskRepository(", @@ -915,26 +865,88 @@ def test_modules_initializer_delegates_agent_composition(): ): assert detail not in initializer_source assert detail in agent_source + assert "AgentChatRuntime(" not in initializer_source + assert "AgentChatRuntime(" not in agent_source + assert "AgentChatRuntime(" in runtime_source assert "runtime.worker.snapshot().capacity" in agent_source assert "startup.initializers" not in agent_source +def test_runtime_composition_is_the_sole_host_and_domain_runtime_owner(): + """HostRuntime、领域 Runtime 与 ApiData 投影只能由单词型 runtime owner 构造。""" + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") + runtime_path = APP_ROOT / "startup" / "composition" / "runtime.py" + runtime_source = runtime_path.read_text(encoding="utf-8") + database_source = (APP_ROOT / "startup" / "composition" / "database.py").read_text(encoding="utf-8") + package_tree = ast.parse((APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8")) + + assert runtime_path.is_file() + assert not list(runtime_path.parent.glob("runtime_*.py")) + assert "compose_runtime_dependencies()" in initializer_source + assert "compose_runtime(" in initializer_source + assert "RuntimeInputs(" in initializer_source + assert "publish_runtime(runtime_composition)" in initializer_source + assert "HostRuntime(" not in initializer_source + assert "ApiDataPorts(" not in initializer_source + assert "ApiDataPorts(" not in database_source + for detail in ( + "class RuntimeDependencies:", + "class RuntimeInputs:", + "class RuntimeComposition:", + "HostRuntime(", + "AgentChatRuntime(", + "AuthenticationRuntime(", + "PersistenceRuntime(", + "MessagingRuntime(", + "HistoryRuntime(", + "SiteRuntime(", + "SubscriptionRuntime(", + "WorkflowRuntime(", + "ApiDataPorts(", + ): + assert detail in runtime_source + constructors = [] + for path in APP_ROOT.rglob("*.py"): + if path.is_relative_to(APP_ROOT / "plugins"): + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if any( + isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "HostRuntime" + for node in ast.walk(tree) + ): + constructors.append(path.relative_to(APP_ROOT).as_posix()) + assert constructors == ["startup/composition/runtime.py"] + context_tree = ast.parse((APP_ROOT / "startup" / "composition" / "context.py").read_text(encoding="utf-8")) + host_runtime = next( + node for node in context_tree.body if isinstance(node, ast.ClassDef) and node.name == "HostRuntime" + ) + task_field = next( + node + for node in host_runtime.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "tasks" + ) + assert task_field.value is None + assert ( + _class_annotations( + APP_ROOT / "startup" / "composition" / "context.py", + "AuthenticationRuntime", + )["passkey_repository"] + == "RepositoryFactory" + ) + assert not any( + isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) for node in package_tree.body + ) + + def test_modules_initializer_delegates_chain_composition(): """Chain 上下文、壁纸与旧整理命令只由单词型 composition owner 装配。""" - initializer_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") composition_path = APP_ROOT / "startup" / "composition" / "chain.py" composition_source = composition_path.read_text(encoding="utf-8") composition_tree = ast.parse(composition_source, filename=str(composition_path)) - package_tree = ast.parse( - (APP_ROOT / "startup" / "composition" / "__init__.py").read_text( - encoding="utf-8" - ) - ) - architecture_source = ( - PROJECT_ROOT / "docs" / "rules" / "05-architecture.md" - ).read_text(encoding="utf-8") + package_tree = ast.parse((APP_ROOT / "startup" / "composition" / "__init__.py").read_text(encoding="utf-8")) + architecture_source = (PROJECT_ROOT / "docs" / "rules" / "05-architecture.md").read_text(encoding="utf-8") + runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8") assert composition_path.is_file() assert not list(composition_path.parent.glob("chain_*.py")) @@ -958,32 +970,27 @@ def test_modules_initializer_delegates_chain_composition(): ): assert detail not in initializer_source assert detail in composition_source - assert initializer_source.count( - "TransactionalTransferExecutionRepository(" - ) == 1 - assert "transfer_execution=transfer_execution_repository" in initializer_source - assert "transfer_execution_repository=transfer_execution" in composition_source + assert "TransactionalTransferExecutionRepository(" not in initializer_source + assert runtime_source.count("TransactionalTransferExecutionRepository(") == 1 + assert "dependencies=runtime_dependencies" in initializer_source + assert "transfer_execution_repository=dependencies.transfer_execution" in composition_source assert "TransactionalTransferExecutionRepository" not in composition_source transfer_function = next( node for node in composition_tree.body - if isinstance(node, ast.FunctionDef) - and node.name == "execute_legacy_transfer_command" + if isinstance(node, ast.FunctionDef) and node.name == "execute_legacy_transfer_command" ) assert any( - isinstance(node, ast.ImportFrom) - and node.module == "app.chain.transfer.facade" + isinstance(node, ast.ImportFrom) and node.module == "app.chain.transfer.facade" for node in ast.walk(transfer_function) ) assert not any( - isinstance(node, ast.ImportFrom) - and node.module == "app.chain.transfer.facade" + isinstance(node, ast.ImportFrom) and node.module == "app.chain.transfer.facade" for node in composition_tree.body ) assert "`startup/composition/chain.py` owns" in architecture_source assert not any( - isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) - for node in package_tree.body + isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) for node in package_tree.body ) @@ -1181,16 +1188,12 @@ def test_user_chain_and_agent_ports_are_typed_and_orm_free(): def test_startup_injects_user_adapter_instead_of_raw_oper(): """启动组合根不得把无会话 UserOper 注入宿主查询调用面。""" - initializer_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8-sig") - database_source = ( - APP_ROOT / "startup" / "composition" / "database.py" - ).read_text(encoding="utf-8-sig") - chain_source = ( - APP_ROOT / "startup" / "composition" / "chain.py" - ).read_text(encoding="utf-8-sig") - source = initializer_source + database_source + chain_source + initializer_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8-sig") + database_source = (APP_ROOT / "startup" / "composition" / "database.py").read_text(encoding="utf-8-sig") + chain_source = (APP_ROOT / "startup" / "composition" / "chain.py").read_text(encoding="utf-8-sig") + runtime_source = (APP_ROOT / "startup" / "composition" / "runtime.py").read_text(encoding="utf-8-sig") + security_source = (APP_ROOT / "startup" / "composition" / "security.py").read_text(encoding="utf-8-sig") + source = initializer_source + database_source + chain_source + runtime_source + security_source assert "TransactionalUserRepository" in source assert "SqlAlchemyUserRepository" in source @@ -1953,8 +1956,7 @@ def test_host_code_imports_agent_and_llm_symbols_from_owner_modules(): imported = { f"{node.module}.{alias.name}" for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and node.module in {"app.agent", "app.agent.llm"} + if isinstance(node, ast.ImportFrom) and node.module in {"app.agent", "app.agent.llm"} for alias in node.names } if imported: @@ -2192,9 +2194,7 @@ def test_web_agent_non_transport_state_stays_in_application(): "app.runtime.stop", } endpoint_imports = { - node.module - for node in ast.walk(endpoint_tree) - if isinstance(node, ast.ImportFrom) and node.module + node.module for node in ast.walk(endpoint_tree) if isinstance(node, ast.ImportFrom) and node.module } assert required <= application_owners diff --git a/tests/test_chain_runtime_context.py b/tests/test_chain_runtime_context.py index 1e7b2637a..596bf4285 100644 --- a/tests/test_chain_runtime_context.py +++ b/tests/test_chain_runtime_context.py @@ -11,6 +11,7 @@ from app.application.chain.context import ChainRuntimeContext from app.application.configuration import ChainRuntimeConfig from app.chain.base import ChainBase from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher +from app.startup.composition.runtime import RuntimeDependencies def _context() -> ChainRuntimeContext: @@ -125,15 +126,19 @@ def test_chain_composition_registers_lazy_compatibility_provider(monkeypatch) -> context = _context() builder = Mock(return_value=context) register = Mock() - dependencies = { - "message_helper": Mock(), - "message_queue": Mock(), + runtime_dependencies = RuntimeDependencies( + download_history=Mock(), + transfer_history=Mock(), + site=Mock(), + subscription=Mock(), + subscription_history=Mock(), + transfer_execution=Mock(), + message_helper=Mock(), + message_queue=Mock(), + ) + inputs = { + "dependencies": runtime_dependencies, "system_config": Mock(), - "site": Mock(), - "subscription": Mock(), - "download_history": Mock(), - "transfer_history": Mock(), - "transfer_execution": Mock(), "configuration": Mock(), } monkeypatch.setattr(chain_composition, "build_chain_runtime_context", builder) @@ -143,12 +148,12 @@ def test_chain_composition_registers_lazy_compatibility_provider(monkeypatch) -> register, ) - chain_composition.configure_chain_runtime_context(**dependencies) + chain_composition.configure_chain_runtime_context(**inputs) builder.assert_not_called() provider = register.call_args.args[0] assert provider() is context - builder.assert_called_once_with(**dependencies) + builder.assert_called_once_with(**inputs) def test_chain_composition_keeps_legacy_transfer_import_lazy(monkeypatch) -> None: diff --git a/tests/test_configuration_initializer.py b/tests/test_configuration_initializer.py index 5e42fc797..8e5b18a51 100644 --- a/tests/test_configuration_initializer.py +++ b/tests/test_configuration_initializer.py @@ -286,7 +286,7 @@ async def test_modules_startup_failure_stops_database_runtime(monkeypatch) -> No @pytest.mark.asyncio async def test_modules_startup_failure_preserves_original_error_when_cleanup_fails( - monkeypatch, + monkeypatch, ) -> None: """数据库任务清理失败时仍向上层保留原始启动异常。""" startup_error = RuntimeError("startup failed") @@ -343,7 +343,7 @@ async def test_database_worker_owner_is_retained_when_shutdown_fails(monkeypatch @pytest.mark.asyncio async def test_database_runtime_rejects_parallel_start_and_allows_next_lifespan( - monkeypatch, + monkeypatch, ) -> None: """数据库 runtime 在启动中拒绝重入,成功关闭后才允许新 lifespan。""" started = asyncio.Event() @@ -401,7 +401,7 @@ async def test_database_runtime_rejects_parallel_start_and_allows_next_lifespan( @pytest.mark.asyncio async def test_database_runtime_start_failure_does_not_publish_transaction_runners( - monkeypatch, + monkeypatch, ) -> None: """Worker 启动失败时不得留下可调用的无会话事务入口。""" published = [] @@ -461,7 +461,6 @@ async def test_database_service_reset_revokes_transaction_runners() -> None: @pytest.mark.asyncio async def test_startup_failure_revokes_published_database_services(monkeypatch) -> None: """后续启动阶段失败并关闭 worker 后不得留下已失效的全局服务。""" - from app.api.data import get_api_data_ports from app.application.configuration import get_configured_system_config from app.application.plugin.transaction import get_plugin_persistence from app.application.query import get_configured_data_query_service @@ -475,7 +474,6 @@ async def test_startup_failure_revokes_published_database_services(monkeypatch) settings=SimpleNamespace(update=lambda _key, _value: (True, "")), ) database = SimpleNamespace( - api_data=object(), data_query=object(), plugin_persistence=object(), workflow_query=object(), @@ -503,7 +501,6 @@ async def test_startup_failure_revokes_published_database_services(monkeypatch) get_configured_data_query_service, get_plugin_persistence, get_configured_workflow_query, - get_api_data_ports, ): with pytest.raises(RuntimeError): getter() diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index eecfe9886..26fde963c 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -23,6 +23,7 @@ from app.application.configuration import ( RuntimeSettingsService, SchedulerRuntimeConfig, ) +from app.runtime.tasks import TaskRegistry from app.startup import lifecycle from app.startup.composition.context import ( AgentChatRuntime, @@ -90,7 +91,6 @@ class _Outbox: """模拟暂存 durable intent。""" - class _DispatchStore: """提供订阅即时副作用所需的独立派发存储替身。""" @@ -125,6 +125,7 @@ class _RuntimeSettings: def _runtime() -> HostRuntime: """构造不加载数据库引擎或 PluginManager 的假宿主运行时。""" + async def async_session(): """生成一个可被 FastAPI 依赖缓存的会话标记。""" yield object() @@ -165,6 +166,7 @@ def _runtime() -> HostRuntime: ), authentication=AuthenticationRuntime( user_repository=_Repository, + passkey_repository=_Repository, standalone_user=lambda: _Repository(object()), system_config=lambda: _Repository(object()), passkey=lambda: _Repository(object()), @@ -205,12 +207,29 @@ def _runtime() -> HostRuntime: configuration=RuntimeConfiguration( api=lambda: ApiRuntimeConfig(False, 60, False, True), scheduler=lambda: SchedulerRuntimeConfig( - False, "Asia/Shanghai", 1, False, "", None, None, - False, 24, "rss", 30, False, None, None, True, 1, False, None, + False, + "Asia/Shanghai", + 1, + False, + "", + None, + None, + False, + 24, + "rss", + 30, + False, + None, + None, + True, + 1, + False, + None, ), chain=lambda: ChainRuntimeConfig(media_extensions=(".mkv",)), ), settings=RuntimeSettingsService(_RuntimeSettings()), + tasks=TaskRegistry(), ) @@ -260,17 +279,13 @@ def test_string_api_data_locator_is_confined_to_compatibility_boundary() -> None if path.is_relative_to(PROJECT_ROOT / "app" / "plugins"): continue tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - imported_modules = { - node.module - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) and node.module - } + imported_modules = {node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module} if imported_modules & {"app.api.data", "app.api.dependencies.data"}: importers.add(path.relative_to(PROJECT_ROOT).as_posix()) assert importers == { "app/api/dependencies/data.py", - "app/startup/composition/database.py", + "app/startup/composition/runtime.py", } @@ -293,7 +308,7 @@ async def test_lifecycle_component_attaches_init_modules_result(monkeypatch) -> @pytest.mark.asyncio async def test_lifecycle_component_revokes_host_runtime_after_converged_stop( - monkeypatch, + monkeypatch, ) -> None: """模块 owner 完整关闭后 AppState 不得继续暴露上一 lifespan 的运行时。""" app = FastAPI() @@ -307,7 +322,7 @@ async def test_lifecycle_component_revokes_host_runtime_after_converged_stop( @pytest.mark.asyncio async def test_lifecycle_component_retains_host_runtime_after_failed_stop( - monkeypatch, + monkeypatch, ) -> None: """模块 owner 未收敛时保留 HostRuntime,供诊断与后续重试。""" app = FastAPI() diff --git a/tests/test_runtime_composition.py b/tests/test_runtime_composition.py new file mode 100644 index 000000000..973afa1ba --- /dev/null +++ b/tests/test_runtime_composition.py @@ -0,0 +1,229 @@ +"""宿主运行时组合根的对象身份、发布与冷导入测试。""" + +from __future__ import annotations + +import json +import subprocess +import sys +from types import SimpleNamespace +from unittest.mock import Mock + +from app.runtime.tasks import TaskRegistry +from app.startup.composition import runtime as runtime_composition + + +def _inputs() -> runtime_composition.RuntimeInputs: + """构造不连接数据库、不启动线程的最小运行时输入。""" + dependencies = runtime_composition.RuntimeDependencies( + download_history=Mock(name="download_history"), + transfer_history=Mock(name="transfer_history"), + site=Mock(name="site"), + subscription=Mock(name="subscription"), + subscription_history=Mock(name="subscription_history"), + transfer_execution=Mock(name="transfer_execution"), + message_helper=Mock(name="message_helper"), + message_queue=Mock(name="message_queue"), + ) + system_config = Mock(name="system_config") + configuration = SimpleNamespace( + system_config=system_config, + runtime=Mock(name="runtime_configuration"), + settings=Mock(name="runtime_settings"), + ) + database = SimpleNamespace(workflow_query=Mock(name="workflow_query")) + agent_data = SimpleNamespace( + sites=dependencies.site, + subscriptions=dependencies.subscription, + subscription_history=dependencies.subscription_history, + transfer_history=dependencies.transfer_history, + transfer_execution=dependencies.transfer_execution, + download_history=dependencies.download_history, + ) + agent = SimpleNamespace( + data=agent_data, + chat_repository=Mock(name="chat_repository"), + persistence=Mock(name="chat_persistence"), + ) + authentication = SimpleNamespace( + user_repository=Mock(name="user_repository"), + passkey_repository=Mock(name="passkey_repository"), + standalone_user=Mock(name="standalone_user"), + system_config=Mock(name="standalone_system_config"), + passkey=Mock(name="standalone_passkey"), + ) + return runtime_composition.RuntimeInputs( + configuration=configuration, + database=database, + agent=agent, + authentication=authentication, + dependencies=dependencies, + tasks=TaskRegistry(), + ) + + +def test_runtime_composition_reuses_dependencies_and_projects_api_identity() -> None: + """HostRuntime、Agent、Chain 依赖与旧 API 投影必须保持同一对象身份。""" + inputs = _inputs() + + composition = runtime_composition.compose_runtime(inputs) + runtime = composition.runtime + ports = composition.api_data + + assert composition.dependencies is inputs.dependencies + assert runtime.agent is inputs.agent.data + assert runtime.agent_chat.repository is inputs.agent.chat_repository + assert runtime.agent_chat.persistence is inputs.agent.persistence + assert runtime.authentication.user_repository is inputs.authentication.user_repository + assert runtime.authentication.passkey_repository is inputs.authentication.passkey_repository + assert runtime.configuration is inputs.configuration.runtime + assert runtime.settings is inputs.configuration.settings + assert runtime.tasks is inputs.tasks + assert runtime.messaging.helper is inputs.dependencies.message_helper + assert runtime.messaging.queue is inputs.dependencies.message_queue + assert runtime.history.transfer_repository is inputs.dependencies.transfer_history + assert runtime.history.transfer_execution_repository is inputs.dependencies.transfer_execution + assert runtime.site.standalone is inputs.dependencies.site + assert inputs.agent.data.transfer_execution is runtime.history.transfer_execution_repository + assert inputs.agent.data.sites is runtime.site.standalone + assert ports.sync_session is runtime.persistence.sync_session + assert ports.async_session is runtime.persistence.async_session + assert ports.repositories == { + "download_history": runtime.history.download_repository, + "media_server": runtime.history.media_server_repository, + "message": runtime.messaging.repository, + "passkey": runtime.authentication.passkey_repository, + "site": runtime.site.repository, + "subscribe": runtime.subscription.repository, + "subscribe_history": runtime.subscription.history_repository, + "user": runtime.authentication.user_repository, + "workflow": runtime.workflow.repository, + } + assert ports.standalone == { + "passkey": runtime.authentication.passkey, + "system_config": runtime.authentication.system_config, + "user": runtime.authentication.standalone_user, + } + assert ports.unit_of_work == { + "async": runtime.persistence.async_transaction, + "sync": runtime.persistence.sync_transaction, + } + assert composition.site_query._repository is inputs.dependencies.site + assert composition.site_health._repository is inputs.dependencies.site + + +def test_runtime_composition_publishes_and_resets_exact_projection(monkeypatch) -> None: + """运行时 owner 应发布并按对称顺序撤销其拥有的全部投影。""" + composition = runtime_composition.compose_runtime(_inputs()) + published: dict[str, object] = {} + reset_order: list[str] = [] + + monkeypatch.setattr( + runtime_composition, + "configure_api_data_runtime", + lambda value: published.update(api=value), + ) + monkeypatch.setattr( + runtime_composition, + "configure_transfer_history_repository", + lambda value: published.update(transfer=value), + ) + monkeypatch.setattr( + runtime_composition, + "configure_site_query_service", + lambda value: published.update(site_query=value), + ) + monkeypatch.setattr( + runtime_composition, + "configure_site_health_service", + lambda value: published.update(site_health=value), + ) + monkeypatch.setattr( + runtime_composition, + "reset_site_health_service", + lambda: reset_order.append("site_health"), + ) + monkeypatch.setattr( + runtime_composition, + "reset_site_query_service", + lambda: reset_order.append("site_query"), + ) + monkeypatch.setattr( + runtime_composition, + "reset_transfer_history_repository", + lambda: reset_order.append("transfer_history"), + ) + monkeypatch.setattr( + runtime_composition, + "reset_api_data_runtime", + lambda: reset_order.append("api_data"), + ) + + runtime_composition.publish_runtime(composition) + runtime_composition.reset_runtime() + + assert published["api"] is composition.api_data + assert published["transfer"]() is composition.dependencies.transfer_history + assert published["site_query"] is composition.site_query + assert published["site_health"] is composition.site_health + assert reset_order == [ + "site_health", + "site_query", + "transfer_history", + "api_data", + ] + + +def test_runtime_composition_import_is_cold() -> None: + """导入 runtime owner 不得加载 initializer、启动 worker/队列或发布端口。""" + completed = subprocess.run( + [ + sys.executable, + "-c", + r""" +import json +import sys + +from app.api.data import get_api_data_ports +from app.application.history import get_transfer_history_repository +from app.application.messaging.message import MessageHelper, MessageQueueManager +from app.application.site.health import get_configured_site_health_service +from app.application.site.query import get_configured_site_query_service + +import app.startup.composition.runtime + +unconfigured = [] +for getter in ( + get_api_data_ports, + get_transfer_history_repository, + get_configured_site_health_service, + get_configured_site_query_service, +): + try: + getter() + except RuntimeError: + unconfigured.append(True) + +print(json.dumps({ + "database_worker_loaded": "app.db.worker" in sys.modules, + "initializer_loaded": any( + name == "app.startup.initializers" or name.startswith("app.startup.initializers.") + for name in sys.modules + ), + "message_helper_created": MessageHelper.get_existing_instance() is not None, + "message_queue_created": MessageQueueManager.get_existing_instance() is not None, + "unconfigured": len(unconfigured), +})) +""", + ], + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(completed.stdout) == { + "database_worker_loaded": False, + "initializer_loaded": False, + "message_helper_created": False, + "message_queue_created": False, + "unconfigured": 4, + }