refactor(startup): centralize runtime composition

This commit is contained in:
jxxghp
2026-08-29 18:34:36 +08:00
parent 6d8a4667ee
commit 32cdcfcc2f
19 changed files with 853 additions and 509 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)
# 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。

View File

@@ -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 重复与测试存量

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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