refactor: add configuration dependency ratchet

This commit is contained in:
jxxghp
2026-08-21 21:20:56 +08:00
parent 9df599f502
commit ff0ce8d1c1
16 changed files with 671 additions and 34 deletions
+12 -1
View File
@@ -28,7 +28,13 @@ def configure_plugin_system_services():
decode_access_token,
)
from app.api.data import configure_api_data_ports
from app.application.configuration import SystemConfigService, configure_system_config
from app.application.configuration import (
SystemConfigService,
TransferRetryConfig,
configure_system_config,
configure_transfer_retry_config,
)
from app.runtime.config import settings
from app.application.service import configure_service_directory
from app.db.session import (
SessionFactory,
@@ -41,6 +47,11 @@ def configure_plugin_system_services():
configure_token_codec(create_access_token, decode_access_token)
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
configure_transfer_retry_config(
lambda: TransferRetryConfig(
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
)
)
from app.application.chain.data import configure_chain_data_ports
from app.application.subscription.write import configure_subscribe_writer
from app.application.plugin.runtime import configure_plugin_runtime
@@ -0,0 +1,250 @@
{
"schema_version": 1,
"scope": {
"excluded": [
"app/plugins",
"app/sdk",
"app/runtime/compat"
],
"root": "app"
},
"settings_imports": {
"count": 169,
"files": [
"app/adapters/cache/backends.py",
"app/adapters/cache/redis.py",
"app/adapters/external/cookiecloud.py",
"app/adapters/external/market.py",
"app/adapters/external/ocr.py",
"app/adapters/external/server.py",
"app/adapters/network/browser.py",
"app/adapters/network/doh.py",
"app/adapters/system/fsproxy.py",
"app/adapters/system/plugin/dependency.py",
"app/adapters/system/plugin/package.py",
"app/adapters/system/resource.py",
"app/adapters/system/rust.py",
"app/adapters/web/security/access.py",
"app/agent/capabilities/adapter.py",
"app/agent/llm/capability.py",
"app/agent/llm/helper.py",
"app/agent/llm/provider.py",
"app/agent/memory/__init__.py",
"app/agent/orchestrator.py",
"app/agent/prompt/__init__.py",
"app/agent/runtime.py",
"app/agent/skills/registry.py",
"app/agent/tools/base.py",
"app/agent/tools/impl/_plugin_tool_utils.py",
"app/agent/tools/impl/_terminal_session.py",
"app/agent/tools/impl/add_download_tasks.py",
"app/agent/tools/impl/create_agent_task.py",
"app/agent/tools/impl/query_agent_tasks.py",
"app/agent/tools/impl/query_system_settings.py",
"app/agent/tools/impl/recognize_media.py",
"app/agent/tools/impl/scrape_metadata.py",
"app/agent/tools/impl/search_web.py",
"app/agent/tools/impl/send_voice_message.py",
"app/agent/tools/impl/update_agent_task.py",
"app/agent/tools/impl/update_system_settings.py",
"app/api/endpoints/agent.py",
"app/api/endpoints/anthropic.py",
"app/api/endpoints/dashboard.py",
"app/api/endpoints/history.py",
"app/api/endpoints/login.py",
"app/api/endpoints/media.py",
"app/api/endpoints/message.py",
"app/api/endpoints/openai.py",
"app/api/endpoints/plugin.py",
"app/api/endpoints/storage.py",
"app/api/endpoints/subscribe.py",
"app/api/endpoints/system.py",
"app/api/endpoints/tmdb.py",
"app/api/endpoints/torrent.py",
"app/api/endpoints/transfer.py",
"app/api/servcookie.py",
"app/application/formatting.py",
"app/application/image.py",
"app/application/maintenance.py",
"app/application/rss.py",
"app/application/security/auth.py",
"app/application/security/passkey.py",
"app/application/security/token.py",
"app/application/security/url.py",
"app/application/torrent.py",
"app/chain/_messaging.py",
"app/chain/_recognition.py",
"app/chain/_transfer.py",
"app/chain/download.py",
"app/chain/interaction.py",
"app/chain/media.py",
"app/chain/message.py",
"app/chain/recommend.py",
"app/chain/scraping.py",
"app/chain/search.py",
"app/chain/site.py",
"app/chain/storage.py",
"app/chain/subscribe.py",
"app/chain/system.py",
"app/chain/torrents.py",
"app/chain/transfer.py",
"app/chain/user.py",
"app/cli.py",
"app/db/base.py",
"app/db/engine.py",
"app/db/session.py",
"app/doctor/checks.py",
"app/doctor/runner.py",
"app/factory.py",
"app/main.py",
"app/modules/acoustid/__init__.py",
"app/modules/anilist/__init__.py",
"app/modules/anilist/anilist.py",
"app/modules/bangumi/__init__.py",
"app/modules/bangumi/bangumi.py",
"app/modules/discord/discord.py",
"app/modules/douban/__init__.py",
"app/modules/douban/apiv2.py",
"app/modules/emby/emby.py",
"app/modules/fanart/__init__.py",
"app/modules/feishu/feishu.py",
"app/modules/filemanager/module.py",
"app/modules/filemanager/storages/alipan.py",
"app/modules/filemanager/storages/alist.py",
"app/modules/filemanager/storages/local.py",
"app/modules/filemanager/storages/rclone.py",
"app/modules/filemanager/storages/smb.py",
"app/modules/filemanager/storages/u115.py",
"app/modules/filemanager/transhandler.py",
"app/modules/indexer/parser/__init__.py",
"app/modules/indexer/parser/rousi.py",
"app/modules/indexer/spider/__init__.py",
"app/modules/indexer/spider/haidan.py",
"app/modules/indexer/spider/hddolby.py",
"app/modules/indexer/spider/mtorrent.py",
"app/modules/indexer/spider/rousi.py",
"app/modules/indexer/spider/sunnypt.py",
"app/modules/indexer/spider/tnode.py",
"app/modules/indexer/spider/torrentleech.py",
"app/modules/indexer/spider/yema.py",
"app/modules/jellyfin/jellyfin.py",
"app/modules/listenbrainz/__init__.py",
"app/modules/lrclib/__init__.py",
"app/modules/musicbrainz/__init__.py",
"app/modules/musicbrainz/music_cache.py",
"app/modules/postgresql/__init__.py",
"app/modules/qbittorrent/__init__.py",
"app/modules/qqbot/qqbot.py",
"app/modules/redis/__init__.py",
"app/modules/rtorrent/__init__.py",
"app/modules/slack/slack.py",
"app/modules/subtitle/__init__.py",
"app/modules/telegram/telegram.py",
"app/modules/theaudiodb/__init__.py",
"app/modules/themoviedb/__init__.py",
"app/modules/themoviedb/category.py",
"app/modules/themoviedb/scraper.py",
"app/modules/themoviedb/tmdb_cache.py",
"app/modules/themoviedb/tmdbapi.py",
"app/modules/themoviedb/tmdbv3api/objs/discover.py",
"app/modules/themoviedb/tmdbv3api/tmdb.py",
"app/modules/thetvdb/__init__.py",
"app/modules/thetvdb/tvdb_v4_official.py",
"app/modules/transmission/__init__.py",
"app/modules/trimemedia/api.py",
"app/modules/webpush/__init__.py",
"app/modules/wechat/wechatbot.py",
"app/modules/wechatclawbot/wechatclawbot.py",
"app/monitor/dispatcher.py",
"app/monitor/monitor.py",
"app/monitor/snapshot.py",
"app/monitor/syslimits.py",
"app/monitor/watcher.py",
"app/runtime/extensions/host_module_adapter.py",
"app/runtime/extensions/module_manager.py",
"app/runtime/extensions/plugin/catalog.py",
"app/runtime/extensions/plugin_manager.py",
"app/runtime/state.py",
"app/runtime/thread.py",
"app/scheduler.py",
"app/startup/agent_initializer.py",
"app/startup/database.py",
"app/startup/database_initializer.py",
"app/startup/domain_initializer.py",
"app/startup/lifecycle/__init__.py",
"app/startup/modules_initializer.py",
"app/startup/plugins_initializer.py",
"app/startup/routers_initializer.py",
"app/workflow/actions/add_subscribe.py",
"app/workflow/actions/fetch_medias.py",
"app/workflow/actions/fetch_rss.py",
"app/workflow/actions/scan_file.py",
"app/workflow/actions/send_message.py"
]
},
"system_config_oper_constructions": {
"calls": [
{
"file": "app/scheduler.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/modules_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/modules_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/modules_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/modules_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/modules_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/modules_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/modules_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/plugins_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/plugins_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/plugins_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/plugins_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/plugins_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/plugins_initializer.py",
"name": "SystemConfigOper"
},
{
"file": "app/startup/plugins_initializer.py",
"name": "SystemConfigOper"
}
],
"count": 15
}
}
+4 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6203,
"edge_sha256": "75ac7a20854abb707ea6c851326a183873a64e93f67053ffcd79ca5b01a105f6",
"edge_count": 6204,
"edge_sha256": "9a988284502bb26fae5266321765fbf0e10b9fe608fd448c6868e67a824ea862",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2464,6 +2464,8 @@
"app.application.formatting -> app.schemas",
"app.application.formatting -> app.schemas.transfer",
"app.application.formatting -> app.schemas.workflow",
"app.application.history -> app.application",
"app.application.history -> app.application.configuration",
"app.application.history -> app.domain",
"app.application.history -> app.domain.context",
"app.application.history -> app.domain.meta",
@@ -2473,7 +2475,6 @@
"app.application.history -> app.foundation.text",
"app.application.history -> app.runtime",
"app.application.history -> app.runtime.cache",
"app.application.history -> app.runtime.config",
"app.application.history -> app.runtime.log",
"app.application.history -> app.schemas",
"app.application.history -> app.schemas.history",
+15
View File
@@ -16,6 +16,21 @@ from app.schemas.types import SystemConfigKey
class TestAgentSystemSettingsTools(unittest.TestCase):
def test_query_system_settings_accepts_injected_reader(self):
"""Agent 配置工具通过窄端口读取授权字段,无需自行构造数据库 Oper。"""
reader = MagicMock()
reader.get.return_value = [{"name": "qb", "enabled": True}]
tool = QuerySystemSettingsTool(
session_id="session-injected",
user_id="10001",
system_config=reader,
)
payload = json.loads(asyncio.run(tool.run(setting_key="Downloaders")))
self.assertTrue(payload["success"])
reader.get.assert_called_once_with(SystemConfigKey.Downloaders)
def test_query_system_settings_returns_exact_systemconfig_value(self):
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
+45
View File
@@ -67,6 +67,28 @@ def _transaction_sample(methods: list[dict[str, str]]) -> dict:
}
def _configuration_sample(
settings_files: list[str],
oper_calls: list[dict[str, str]],
) -> dict:
"""构造最小配置债务 fixture,供单向 ratchet 行为测试。"""
return {
"schema_version": 1,
"scope": {
"root": "app",
"excluded": ["app/plugins", "app/sdk", "app/runtime/compat"],
},
"settings_imports": {
"count": len(settings_files),
"files": settings_files,
},
"system_config_oper_constructions": {
"count": len(oper_calls),
"calls": oper_calls,
},
}
def test_architecture_legacy_action_requires_scope(capsys):
"""旧操作未明确宿主或插件范围时必须拒绝执行。"""
with pytest.raises(SystemExit) as error:
@@ -259,6 +281,29 @@ def test_transaction_ratchet_allows_removal_but_rejects_new_method() -> None:
)
def test_configuration_ratchet_allows_removal_but_rejects_new_access() -> None:
"""配置债务低水位允许下降,但新增或换位置的直接访问必须失败。"""
existing_call = {"file": "app/startup/demo.py", "name": "SystemConfigOper"}
new_call = {"file": "app/application/demo.py", "name": "SystemConfigOper"}
expected = _configuration_sample(["app/application/old.py"], [existing_call])
assert architecture_baseline.configuration_ratchet_matches(
expected,
_configuration_sample([], []),
)
assert not architecture_baseline.configuration_ratchet_matches(
expected,
_configuration_sample(
["app/application/old.py", "app/application/new.py"],
[existing_call],
),
)
assert not architecture_baseline.configuration_ratchet_matches(
expected,
_configuration_sample(["app/application/old.py"], [new_call]),
)
def test_architecture_write_host_only_updates_host_files(
tmp_path: Path,
monkeypatch,
@@ -17,6 +17,7 @@ def test_architecture_contract_baselines_match_current_source():
BASELINE_ROOT / "dependency-baseline.json",
BASELINE_ROOT / "runtime-contract-baseline.json",
BASELINE_ROOT / "transaction-debt-baseline.json",
BASELINE_ROOT / "configuration-debt-baseline.json",
)
contents_before = {
path: path.read_bytes()
@@ -132,6 +133,25 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
assert baseline["oper_session_factories"] == {"count": 0, "calls": []}
def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None:
"""配置债务基线必须排除插件兼容面,并冻结两个可下降的直接访问集合。"""
baseline_path = BASELINE_ROOT / "configuration-debt-baseline.json"
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 1
assert baseline["scope"]["excluded"] == [
"app/plugins",
"app/sdk",
"app/runtime/compat",
]
assert baseline["settings_imports"]["count"] == len(
baseline["settings_imports"]["files"]
)
assert baseline["system_config_oper_constructions"]["count"] == len(
baseline["system_config_oper_constructions"]["calls"]
)
def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources():
"""非功能基线必须同时记录正常/安全模式和隔离资源增量。"""
baseline_path = BASELINE_ROOT / "startup-performance-baseline.json"
+21
View File
@@ -1,6 +1,8 @@
import asyncio
from unittest.mock import MagicMock, patch
from app.modules.bangumi import BangumiModule
from app.runtime.config import settings
class _FakeBangumiApi:
@@ -68,3 +70,22 @@ def test_async_bangumi_person_detail_normalizes_numeric_birthday():
person = asyncio.run(module.async_bangumi_person_detail(1002))
assert person.birthday == "19"
def test_bangumi_test_uses_generation_snapshot_until_reload(monkeypatch):
"""长生命周期模块应在 init/reload 时换快照,而不是每次调用读取全局配置。"""
module = BangumiModule()
monkeypatch.setattr(settings, "PROXY_HOST", "http://old-proxy")
module.init_module()
old_proxy = settings.PROXY
monkeypatch.setattr(settings, "PROXY_HOST", "http://new-proxy")
new_proxy = settings.PROXY
with patch("app.modules.bangumi.RequestUtils") as request_utils:
request_utils.return_value.get_res.return_value = MagicMock(status_code=200)
module.test()
assert request_utils.call_args.kwargs["proxies"] == old_proxy
module.on_config_changed()
module.test()
assert request_utils.call_args.kwargs["proxies"] == new_proxy
+47
View File
@@ -0,0 +1,47 @@
"""配置快照与窄读写端口测试。"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
from app.application.configuration import (
SystemConfigService,
TransferRetryConfig,
configure_transfer_retry_config,
get_transfer_retry_config,
)
def test_system_config_service_supports_separate_reader_and_writer() -> None:
"""应用服务可以分别注入只读与写入适配器。"""
reader = MagicMock()
reader.get.return_value = "old"
reader.async_get = AsyncMock(return_value="async-old")
writer = MagicMock()
writer.set.return_value = True
writer.async_set = AsyncMock(return_value=True)
service = SystemConfigService(reader=reader, writer=writer)
assert service.get("key") == "old"
assert service.set("key", "new") is True
assert asyncio.run(service.async_get("key")) == "async-old"
assert asyncio.run(service.async_set("key", "new")) is True
service.delete("key")
reader.get.assert_called_once_with("key")
writer.set.assert_called_once_with("key", "new")
writer.delete.assert_called_once_with("key")
def test_transfer_retry_provider_returns_frozen_snapshot_per_call() -> None:
"""配置工厂在每次用例入口创建新快照,旧快照不受 reload 后状态影响。"""
state = {"value": 2}
configure_transfer_retry_config(
lambda: TransferRetryConfig(max_failed_retries=state["value"])
)
before_reload = get_transfer_retry_config()
state["value"] = 4
after_reload = get_transfer_retry_config()
assert before_reload.max_failed_retries == 2
assert after_reload.max_failed_retries == 4