From d51320103ce9540bf83481e9b50d7fdfeb94d542 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:49:13 +0800 Subject: [PATCH] fix: make local setup and bundled skill operations recoverable (#7150) * fix: make local setup and bundled skill operations recoverable * test: verify user updates with real transaction runners --- app/db/oper/user.py | 20 ++ app/db/uow.py | 23 +- app/startup/composition/cliagent.py | 218 ++++++++++++ database/gen.py | 7 +- database/versions/41ef1dd7467c_2_2_2.py | 4 +- docker/entrypoint.sh | 3 +- docs/architecture-overview.md | 4 +- docs/cli.md | 5 + docs/development-setup.md | 2 + docs/rules/05-architecture.md | 9 + scripts/architecture/baseline.py | 7 +- scripts/architecture/coverage_ratchet.py | 18 +- scripts/architecture/service_locator.py | 2 + scripts/bootstrap-local.sh | 10 +- scripts/evaluation/score.py | 6 +- scripts/generate_agent_skill_docs.py | 4 + scripts/local_setup.py | 145 ++++---- scripts/perf/moviepilot_docker_ab.py | 3 +- scripts/start-local.sh | 8 + skills/anysearch/README.md | 4 + skills/anysearch/SKILL.md | 2 +- skills/anysearch/scripts/anysearch_cli.js | 7 + skills/anysearch/scripts/anysearch_cli.ps1 | 9 + skills/anysearch/scripts/anysearch_cli.py | 4 + skills/anysearch/scripts/anysearch_cli.sh | 19 +- skills/anysearch/scripts/generate.py | 2 +- skills/database-operation/SKILL.md | 5 +- skills/database-operation/scripts/mp-db.py | 103 +++++- skills/feedback-issue/SKILL.md | 2 +- .../scripts/collect_feedback_diagnostics.py | 3 +- skills/publish-moviepilot-plugin/SKILL.md | 11 +- .../scripts/publish_plugin.py | 35 +- .../architecture/concurrency-baseline.json | 7 + .../architecture/dependency-baseline.json | 48 ++- tests/test_agent_skill_docs_generator.py | 3 + tests/test_builtin_skill_boundaries.py | 7 +- tests/test_cli_agent_review_runtime.py | 199 +++++++++++ tests/test_dev_skills_review_boundaries.py | 66 ++++ tests/test_dev_skills_review_regressions.py | 284 ++++++++++++++++ tests/test_feedback_issue_log_quality.py | 16 + tests/test_local_setup_frontend_version.py | 4 +- tests/test_local_setup_review_regressions.py | 318 ++++++++++++++++++ tests/test_service_locator_gate.py | 21 ++ tests/test_user_oper_update.py | 174 ++++++++++ 44 files changed, 1720 insertions(+), 131 deletions(-) create mode 100644 app/startup/composition/cliagent.py create mode 100644 tests/test_cli_agent_review_runtime.py create mode 100644 tests/test_dev_skills_review_boundaries.py create mode 100644 tests/test_dev_skills_review_regressions.py create mode 100644 tests/test_local_setup_review_regressions.py create mode 100644 tests/test_user_oper_update.py diff --git a/app/db/oper/user.py b/app/db/oper/user.py index 624730265..38128bbd7 100644 --- a/app/db/oper/user.py +++ b/app/db/oper/user.py @@ -13,6 +13,7 @@ from typing import Any, List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session from app.db.base import DbOper from app.db.models.user import User @@ -53,6 +54,25 @@ class UserOper(DbOper): lambda session: User.get_by_id(session, user_id) ) + def update(self, user_id: int, payload: dict[str, Any]) -> Optional[User]: + """ + 在调用方或独占短事务内重新查询并更新用户,不使用游离对象的旧会话。 + + :param user_id: 要更新的用户主键 + :param payload: 本次要修改的用户字段 + :return: 更新后的用户;已删除时返回 None + """ + def stage(session: Session) -> Optional[User]: + """ + 在同一有效会话内查询并暂存更新。 + """ + user = User.get_by_id(session, user_id) + if user is not None: + user.update(session, payload) + return user + + return self._execute_sync_write(stage) + async def async_list(self) -> List[User]: """ 异步获取用户列表。 diff --git a/app/db/uow.py b/app/db/uow.py index 1fc77677d..30ab2a143 100644 --- a/app/db/uow.py +++ b/app/db/uow.py @@ -3,7 +3,8 @@ SQLAlchemy 请求级事务适配器与无会话 Oper 事务执行端口。 """ from collections.abc import Awaitable, Callable -from typing import Protocol, TypeVar +from contextlib import contextmanager +from typing import Iterator, Protocol, TypeVar from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -70,6 +71,26 @@ def reset_transaction_runners() -> None: _async_transaction_runner = None +@contextmanager +def transaction_runner_scope( + *, sync: SyncTransactionRunner, async_: AsyncTransactionRunner, +) -> Iterator[None]: + """ + 为独立初始化命令登记事务执行器,并在退出时恢复进入前的端口。 + + :param sync: 当前命令使用的独占同步事务执行器 + :param async_: 当前命令使用的独占异步事务执行器 + :return: 退出后恢复原登记状态的同步上下文 + """ + global _sync_transaction_runner, _async_transaction_runner + previous = (_sync_transaction_runner, _async_transaction_runner) + configure_transaction_runners(sync=sync, async_=async_) + try: + yield + finally: + _sync_transaction_runner, _async_transaction_runner = previous + + def run_sync_transaction(operation: Callable[[Session], T]) -> T: """ 委托组合根在独占同步事务中执行 Oper 操作。 diff --git a/app/startup/composition/cliagent.py b/app/startup/composition/cliagent.py new file mode 100644 index 000000000..701dcff35 --- /dev/null +++ b/app/startup/composition/cliagent.py @@ -0,0 +1,218 @@ +""" +为一次命令行 Agent 请求装配数据、配置和能力运行时,并按依赖顺序收口。 + +此组合根不启动 Web、前端、站点认证、插件同步或定时服务;按需工具仍使用宿主端口。 +""" + +from __future__ import annotations + +import asyncio +from contextlib import AsyncExitStack +from functools import partial +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.runtime.config import Settings + from app.runtime.tasks import TaskRegistry + from app.startup.composition.agent import AgentComposition + from app.startup.composition.classification import ClassificationComposition + from app.startup.composition.configuration import ConfigurationComposition + from app.startup.composition.database import DatabaseRuntime + + +def _compose_cli_agent( + database: DatabaseRuntime, + configuration: ConfigurationComposition, + classification: ClassificationComposition, + tasks: TaskRegistry, +) -> AgentComposition: + """ + 在线程内复用宿主组合函数,为 Agent 和 Chain 发布同一批持久化端口。 + """ + from app.agent.loader import get_agent_manager + from app.runtime.extensions.plugin.manager import configure_plugin_runtime_factory + from app.startup.composition.agent import compose_agent, publish_agent_services + from app.startup.composition.chain import configure_chain_runtime_context + from app.startup.composition.database import compose_database_services, publish_database_services + from app.startup.composition.runtime import compose_runtime_dependencies + from app.startup.composition.subscription import async_rule_group_mutation_scope + from app.startup.composition.system import compose_system_service + from app.startup.initializers.plugins import build_plugin_runtime_graph + + configure_plugin_runtime_factory(build_plugin_runtime_graph) + publish_database_services(compose_database_services(runtime=database, system_config=configuration.system_config)) + dependencies = compose_runtime_dependencies() + composition = compose_agent( + runtime=database, + system_config=configuration.system_config, + dependencies=dependencies, + tasks=tasks, + ) + system = compose_system_service( + settings=configuration.settings, + system_config=configuration.system_service, + rule_group_mutation=partial(async_rule_group_mutation_scope, configuration.system_config.publish_many), + ) + configure_chain_runtime_context( + dependencies=dependencies, + system_config=configuration.system_config, + configuration=configuration.runtime.chain, + classification_service=classification.execution, + system_service=system, + ) + publish_agent_services(composition, data_context_registrar=get_agent_manager().configure_data_context) + return composition + + +async def _close_cli_extensions(*, events_started: bool) -> None: + """ + 只关闭工具按需物化的宿主模块、插件及共享线程,不反向创建运行时对象。 + """ + from app.runtime.events import EventManager + from app.runtime.execution import run_in_threadpool_to_completion + from app.runtime.extensions.module.manager import ModuleManager + from app.runtime.extensions.plugin.manager import PluginManager + from app.runtime.thread import ThreadHelper + + plugin = PluginManager.get_existing_instance() + events = EventManager.get_existing_instance() + if plugin is not None and not await plugin.quiesce_plugins(timeout=30): + raise RuntimeError("CLI Agent 插件任务尚未收敛") + if events_started and events is not None and await events.drain_async(timeout=30, seal=False) is False: + raise RuntimeError("CLI Agent 在途事件尚未收敛") + if plugin is not None: + if not await plugin.quiesce_plugin_services(timeout=30): + raise RuntimeError("CLI Agent 插件服务尚未收敛") + if await run_in_threadpool_to_completion(plugin.finalize_plugins) is False: + raise RuntimeError("CLI Agent 插件 owner 尚未释放") + modules = ModuleManager.get_existing_instance() + if modules is not None and await run_in_threadpool_to_completion(modules.shutdown) is False: + raise RuntimeError("CLI Agent 模块 owner 尚未释放") + if events is not None: + await events.stop_async() + threads = ThreadHelper.get_existing_instance() + if threads is not None and await run_in_threadpool_to_completion(threads.shutdown) is False: + raise RuntimeError("CLI Agent 共享线程尚未收敛") + + +async def run_cli_agent_request(*, message: str, session_id: str, user_id: str, settings: Settings) -> dict[str, str]: + """ + 在独占 CLI 进程完成一轮对话;成功、模型异常或装配失败均按 owner 收尾。 + + :param message: 已去除首尾空白的用户输入 + :param session_id: 本次使用的会话标识,可复用同一用户的持久历史 + :param user_id: 本地执行者的用户标识 + :param settings: CLI 入口读取并显式注入的部署配置对象 + :return: 实际会话标识及最终捕获文本 + :raises RuntimeError: Agent 未启用、进程已有数据库 owner,或关键关闭未收敛 + """ + from app.startup.initializers.cache import configure_cache_dependencies + + configure_cache_dependencies() + + from app.adapters.network.http import aclose_shared_async_transports + from app.agent.loader import activate_agent_service + from app.db.session import close_database + from app.runtime.events import EventManager + from app.runtime.execution import run_in_threadpool_to_completion + from app.runtime.extensions.plugin.manager import reset_plugin_runtime_factory + from app.runtime.loop import main_loop_registry + from app.runtime.tasks import TaskRegistry, configure_task_registry + from app.schemas.types import NotificationChannel, ReplyMode + from app.startup.composition.agent import reset_agent_services + from app.startup.composition.chain import reset_chain_services + from app.startup.composition.classification import compose_classification + from app.startup.composition.configuration import compose_configuration, publish_configuration, reset_configuration + from app.startup.composition.database import ( + database_runtime_active, + reset_database_services, + start_database_runtime, + stop_database_runtime, + ) + from app.startup.composition.network import configure_application_network_ports, reset_application_network_ports + from app.startup.initializers.agent import configure_agent_ports, reset_agent_ports, stop_agent + from app.startup.initializers.chain import init_chain_ports, reset_chain_ports + from app.startup.initializers.database import prepare_database + from app.startup.initializers.domain import configure_domain_dependencies + from app.startup.initializers.modules import configure_runtime_data_providers, reset_runtime_data_providers + from app.startup.initializers.network import init_chain_network_ports, reset_chain_network_ports + from app.startup.initializers.site import init_site_access_ports, reset_site_access_ports + + if not settings.AI_AGENT_ENABLE: + raise RuntimeError("MoviePilot 智能体未启用,请先在配置中打开 AI_AGENT_ENABLE") + if database_runtime_active(): + raise RuntimeError("CLI Agent 需要独占进程,不能复用已运行的数据库 runtime") + + resources = AsyncExitStack() + composition = None + agent_owned = False + database_owned = False + events_started = False + tasks = TaskRegistry() + loop_owner = main_loop_registry.register(asyncio.get_running_loop()) + configure_task_registry(tasks) + resources.callback(main_loop_registry.release, loop_owner) + resources.callback(configure_task_registry, None) + resources.push_async_callback(aclose_shared_async_transports) + resources.push_async_callback(close_database) + try: + await run_in_threadpool_to_completion(prepare_database) + database = await start_database_runtime() + database_owned = True + configuration = await compose_configuration(executor=database.worker, settings=settings) + resources.callback(reset_configuration) + publish_configuration(configuration, settings) + configure_domain_dependencies() + classification = await compose_classification( + executor=database.worker, + settings=settings, + system_config=configuration.system_config, + ) + resources.callback(reset_chain_services) + resources.callback(reset_agent_services, data_context_resetter=reset_agent_ports) + resources.callback(reset_plugin_runtime_factory) + resources.callback(reset_database_services) + composition = await run_in_threadpool_to_completion(_compose_cli_agent, database, configuration, classification, tasks) + resources.callback(reset_runtime_data_providers) + configure_runtime_data_providers() + resources.callback(reset_application_network_ports) + configure_application_network_ports() + resources.callback(reset_chain_ports) + init_chain_ports() + resources.callback(reset_chain_network_ports) + init_chain_network_ports() + resources.callback(reset_site_access_ports) + init_site_access_ports() + EventManager().start() + events_started = True + configure_agent_ports() + agent_owned = True + manager = await activate_agent_service() + if manager is None: + raise RuntimeError("MoviePilot 智能体启动失败") + output: list[str] = [] + result = await manager.process_message( + session_id=session_id, + user_id=user_id, + message=message, + channel=NotificationChannel.Web.value, + source="cli", + reply_mode=ReplyMode.CAPTURE_ONLY, + output_callback=output.append, + allow_message_tools=False, + wait_for_completion=True, + ) + return {"session_id": session_id, "result": (result if isinstance(result, str) else "".join(output)).strip()} + finally: + # 必须先释放 Agent 及后台任务,才能关闭它们仍可能使用的写入与数据库依赖。 + if agent_owned and await stop_agent() is False: + raise RuntimeError("CLI Agent 尚有任务未收敛,保留其数据库依赖") + if await tasks.shutdown(timeout_seconds=30) is False: + raise RuntimeError("CLI Agent 后台任务未收敛,保留其数据库依赖") + if composition is not None: + composition.persistence.begin_shutdown() + await composition.persistence.shutdown() + await _close_cli_extensions(events_started=events_started) + if database_owned: + await stop_database_runtime() + await resources.aclose() diff --git a/database/gen.py b/database/gen.py index 676412366..b414b10cb 100644 --- a/database/gen.py +++ b/database/gen.py @@ -4,17 +4,14 @@ 本入口调用 Alembic 生成版本文件,不执行生成的升级或降级操作。 """ -import importlib -from pathlib import Path - from alembic.config import Config as AlembicConfig from alembic.command import revision as alembic_revision from app.runtime.config import settings +from app.db.models import load_all_models # 导入模块,避免建表缺失 -for module in Path(__file__).with_name("models").glob("*.py"): - importlib.import_module(f"app.db.models.{module.stem}") +load_all_models() db_version = input("请输入版本号:") db_location = settings.CONFIG_PATH / 'user.db' diff --git a/database/versions/41ef1dd7467c_2_2_2.py b/database/versions/41ef1dd7467c_2_2_2.py index 35ece7456..3967f4883 100644 --- a/database/versions/41ef1dd7467c_2_2_2.py +++ b/database/versions/41ef1dd7467c_2_2_2.py @@ -26,7 +26,7 @@ def upgrade() -> None: select_stmt = text( """ - SELECT id, key, value + SELECT id, key FROM SystemConfig WHERE id NOT IN ( SELECT MAX(id) @@ -38,7 +38,7 @@ def upgrade() -> None: to_delete = connection.execute(select_stmt).fetchall() for row in to_delete: logger.warn( - f"已删除重复的 SystemConfig 项:key={row.key}, value={row.value}, id={row.id}" + f"已删除重复的 SystemConfig 项:key={row.key}, id={row.id}" ) delete_stmt = text("DELETE FROM SystemConfig WHERE id = :id") connection.execute(delete_stmt, {"id": row.id}) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 378d0fa0f..5d0432674 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -507,8 +507,7 @@ fi maybe_reexec_control_bundle cd /app || exit -if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ] \ - && [ -f "${CONFIG_DIR}/temp/moviepilot-update/install.json" ]; then +if [ -f "${CONFIG_DIR}/temp/moviepilot-update/install.json" ]; then if apply_pending_release_update_at_startup; then INFO "→ 未完成的 Release 更新已安装,重新执行入口加载新代码。" reexec_entrypoint diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 4fda81e35..663b41021 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -816,8 +816,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1128 | -| 内部导入边 | 9,649 | +| Python 模块 | 1129 | +| 内部导入边 | 9,690 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/docs/cli.md b/docs/cli.md index fea5640b1..bc89db7de 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -219,6 +219,7 @@ moviepilot install frontend --config-dir /path/to/moviepilot-config - 如需覆盖默认行为,仍可显式传入 `--version latest` 或指定具体 tag - 会自动安装本地 Node 运行时 - 会自动安装 `service.js` 所需的运行依赖 +- 新版前端及其依赖全部准备成功后才替换当前版本;依赖安装失败时保留已有页面和依赖 安装资源文件: @@ -313,6 +314,8 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst - `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中 - 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码 +- 重复初始化会更新已有账号的权限和显式指定的密码;向导留空保留原密码中的引号与反斜杠 +- 引导脚本的 `--non-interactive` 即使连接终端也跳过向导,`--link-path` 保留显式指定的命令路径 ## 开机自启命令 @@ -408,7 +411,9 @@ moviepilot agent --new-session 帮我总结当前系统配置有什么明显问 说明: - `moviepilot agent` 直接在本地环境里发起一次智能体请求 +- 请在独立 CLI 进程中执行;入口会装配正式数据、配置及 Agent/Chain 能力,完成后按依赖顺序关闭资源 - 默认每次可自动创建新会话,也可以通过 `--session` 指定会话 ID +- 使用相同用户和 `--session` 可在下一次 CLI 调用恢复持久化历史;模型异常或取消也会执行资源关闭 - 使用前需要先正确配置 LLM 相关参数,并打开 `AI_AGENT_ENABLE` ## 服务管理命令 diff --git a/docs/development-setup.md b/docs/development-setup.md index fd6dacf5e..752425b25 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -67,6 +67,8 @@ UV_PROJECT_ENVIRONMENT="${MOVIEPILOT_WORKSPACE}/.venv-test" \ 不需要打开 IDE 时,可以直接使用仓库内的启动脚本。脚本会自动定位项目根目录和虚拟环境,并以模块方式启动后端,避免 `ModuleNotFoundError: No module named 'app'`。 +后端开发入口优先使用 `uv sync` 创建的 `.venv/bin/python`,没有该解释器时使用旧的 `venv/bin/python`。`./scripts/start-local.sh help` 不要求先安装虚拟环境。 + ```bash # 默认启动后端开发服务,前台运行,按 Ctrl+C 停止 ./scripts/start-local.sh diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 7ea4d3c3c..298b36416 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -173,6 +173,15 @@ composition constructs and injects cross-layer dependencies, initializers expose domain-scoped startup/shutdown hooks, and lifecycle orders those hooks and decides restart policy. Reusable persistence implementations belong in `app/db/adapters/`, not startup. Lower-level runtime modules must not import startup. + +`startup/composition/cliagent.py` owns one exclusive CLI request process. It receives +deployment settings explicitly and reuses the database, configuration, Agent and +Chain composition owners. It may hold and close module/plugin lifecycle owners; +this permission applies to that exact module, not its siblings or a subtree. It +does not start Web, frontend, external site authentication or plugin synchronization. +Shutdown drains Agent work and persistence before closing dependent database +owners; an unconverged owner must retain its downstream dependencies and report +failure. `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, diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index f64981704..76660848a 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -142,10 +142,13 @@ def iter_runtime_import_nodes(tree: ast.AST): for node in ast.walk(tree): if not isinstance(node, (ast.Import, ast.ImportFrom)): continue - parent = parents.get(node) + child = node + parent = parents.get(child) while parent is not None: - if isinstance(parent, ast.If) and _is_type_checking_test(parent.test): + if (isinstance(parent, ast.If) and _is_type_checking_test(parent.test) + and child in parent.body): break + child = parent parent = parents.get(parent) else: yield node diff --git a/scripts/architecture/coverage_ratchet.py b/scripts/architecture/coverage_ratchet.py index 924e0e759..8d29698b4 100644 --- a/scripts/architecture/coverage_ratchet.py +++ b/scripts/architecture/coverage_ratchet.py @@ -326,20 +326,12 @@ def _merge_baseline( baseline: Mapping[str, Any], current: Mapping[str, Any] ) -> dict[str, Any]: """ - 只提升逐文件低水位,并移除已删除文件。 + 使用同一份当前计数生成逐文件和汇总低水位;调用方已拒绝覆盖率回退。 """ - old_files = baseline.get("files", {}) - current_files = current["files"] - merged: dict[str, Mapping[str, int | float]] = {} - for path, values in current_files.items(): - previous = old_files.get(path) if isinstance(old_files, Mapping) else None - if previous is not None and not _file_ratio_is_lower(values, previous): - merged[path] = previous - else: - merged[path] = values - result = dict(current) - result["files"] = dict(sorted(merged.items())) - return result + regressions = compare_coverage(baseline, current) + if regressions: + raise ValueError("拒绝写入覆盖率回退:" + "; ".join(regressions)) + return _snapshot_from_files(current["files"]) def main() -> int: diff --git a/scripts/architecture/service_locator.py b/scripts/architecture/service_locator.py index c7191bcec..5ec7494bc 100644 --- a/scripts/architecture/service_locator.py +++ b/scripts/architecture/service_locator.py @@ -64,6 +64,7 @@ RUNTIME_FACADE_POLICIES = ( { "app.sdk.plugin.manager", "app.startup.composition.chain", + "app.startup.composition.cliagent", "app.startup.initializers.modules", } ), @@ -75,6 +76,7 @@ RUNTIME_FACADE_POLICIES = ( { "app.sdk.plugin.manager", "app.startup.composition.chain", + "app.startup.composition.cliagent", "app.startup.initializers.plugins", } ), diff --git a/scripts/bootstrap-local.sh b/scripts/bootstrap-local.sh index 511476cb2..030fd22ff 100755 --- a/scripts/bootstrap-local.sh +++ b/scripts/bootstrap-local.sh @@ -128,7 +128,9 @@ detect_os() { case "$uname_s" in Darwin) OS_NAME="macOS" - if command -v brew >/dev/null 2>&1; then + if [[ -n "$LINK_PATH" ]]; then + : + elif command -v brew >/dev/null 2>&1; then LINK_PATH="$(brew --prefix)/bin/moviepilot" else LINK_PATH="/usr/local/bin/moviepilot" @@ -140,14 +142,14 @@ detect_os() { else OS_NAME="Linux" fi - LINK_PATH="/usr/local/bin/moviepilot" + LINK_PATH="${LINK_PATH:-/usr/local/bin/moviepilot}" ;; MINGW*|MSYS*|CYGWIN*) OS_NAME="Windows" ;; *) OS_NAME="$uname_s" - LINK_PATH="/usr/local/bin/moviepilot" + LINK_PATH="${LINK_PATH:-/usr/local/bin/moviepilot}" ;; esac @@ -683,6 +685,8 @@ ensure_link_path if [[ "$NON_INTERACTIVE" != "true" && "$HAS_TTY" == "true" ]]; then run_interactive_guide ensure_link_path +elif [[ "$NON_INTERACTIVE" == "true" ]]; then + RUN_WIZARD="false" elif [[ "$RUN_WIZARD" == "true" && "$HAS_TTY" != "true" ]]; then echo "==> 未检测到可用终端输入,已跳过初始化向导。安装完成后可手动执行:moviepilot setup --wizard" RUN_WIZARD="false" diff --git a/scripts/evaluation/score.py b/scripts/evaluation/score.py index 6b4410716..d46c69938 100644 --- a/scripts/evaluation/score.py +++ b/scripts/evaluation/score.py @@ -484,12 +484,12 @@ def _check_long_context_claims( event for event in ledger if event.get("operation_id") == "subscription.list" and event.get("outcome") == "succeeded" ] - pages = { + pages = [ event.get("request", {}).get("query", {}).get("page") for event in subscription_events if event.get("request", {}).get("query", {}).get("count") == 20 - } - if pages != set(range(1, 7)): + ] + if pages != list(range(1, 7)): violations.append("long_context_pages_not_verified") target_rows = [ observation.get("record", {}) diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index 8f91be4f8..58a361532 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import re import runpy import sys import textwrap @@ -17,6 +18,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) MOVIEPILOT_API_SKILL_VERSION = "59" +DATABASE_OPERATION_SKILL_VERSION = "12" DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = { @@ -1019,6 +1021,8 @@ def main() -> int: database_path = PROJECT_ROOT / "skills/database-operation/SKILL.md" database_text = database_path.read_text(encoding="utf-8") + database_text = re.sub(r"(?m)^version: \d+$", f"version: {DATABASE_OPERATION_SKILL_VERSION}", + database_text, count=1) database_path.write_text( _replace_section( database_text, diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 12e183196..800eb7ce4 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -27,6 +27,7 @@ import textwrap import urllib.parse import uuid import zipfile +from collections.abc import Callable from datetime import datetime from pathlib import Path from tempfile import TemporaryDirectory @@ -801,8 +802,9 @@ def _serialize_env_value(value: Any) -> str: def read_env_value(key: str) -> Optional[str]: """ - 读取 app.env 中首个匹配键,去除首尾空白与引号。 + 读取 app.env 首个匹配键,反序列化本工具写入的 JSON 字符串并兼容旧单引号。 + :param key: 要读取的配置键 :return: 配置文本;文件不存在或键缺失时返回 None,不解析变量引用 """ for line in _load_env_lines(): @@ -811,7 +813,16 @@ def read_env_value(key: str) -> Optional[str]: continue env_key, value = line.split("=", 1) if env_key.strip() == key: - return value.strip().strip('"').strip("'") + value = value.strip() + if value.startswith('"') and value.endswith('"'): + try: + decoded = json.loads(value) + return decoded if isinstance(decoded, str) else value + except json.JSONDecodeError: + return value[1:-1] + if value.startswith("'") and value.endswith("'"): + return value[1:-1] + return value return None @@ -1136,7 +1147,8 @@ def install_frontend( if archive else f"下载前端发布包:{version_tag}" ) - with TemporaryDirectory() as temp_dir: + PUBLIC_DIR.parent.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix=".frontend-install-", dir=PUBLIC_DIR.parent) as temp_dir: temp_path = Path(temp_dir) archive_path = temp_path / "dist.zip" extract_dir = temp_path / "extract" @@ -1154,36 +1166,40 @@ def install_frontend( or packaged_version.read_text(encoding="utf-8").strip() != version_tag ): raise RuntimeError("前端更新包版本与目标版本不一致") - _remove_path(PUBLIC_DIR) - shutil.move(str(dist_dir), str(PUBLIC_DIR)) - - _write_local_frontend_service_script(PUBLIC_DIR) - - runtime_package = dict(RUNTIME_PACKAGE) - runtime_package["version"] = version_tag - (PUBLIC_DIR / "package.json").write_text( - json.dumps(runtime_package, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - - npm_bin = get_npm_bin() - if not npm_bin.exists(): - raise RuntimeError("未找到 npm 可执行文件,Node 运行时可能损坏") - - print_step("安装前端运行依赖") - run( - [ - str(npm_bin), - "install", - "--no-fund", - "--no-audit", - "--omit=dev", - ], - cwd=PUBLIC_DIR, - ) + _write_local_frontend_service_script(dist_dir) + runtime_package = {**RUNTIME_PACKAGE, "version": version_tag} + (dist_dir / "package.json").write_text( + json.dumps(runtime_package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", + ) + npm_bin = get_npm_bin() + if not npm_bin.exists(): + raise RuntimeError("未找到 npm 可执行文件,Node 运行时可能损坏") + print_step("安装前端运行依赖") + run([str(npm_bin), "install", "--no-fund", "--no-audit", "--omit=dev"], cwd=dist_dir) + _publish_frontend_installation(dist_dir) return {"version": version_tag, "node": str(node_bin)} +def _publish_frontend_installation(staged: Path) -> None: + """ + 同文件系统发布完整前端;发布失败恢复旧目录,恢复失败时保留独立备份。 + + :param staged: 已完成依赖安装且与目标在同一文件系统的候选前端目录 + """ + backup = PUBLIC_DIR.with_name(f".{PUBLIC_DIR.name}.backup-{uuid.uuid4().hex}") + had_previous = PUBLIC_DIR.exists() or PUBLIC_DIR.is_symlink() + if had_previous: + PUBLIC_DIR.rename(backup) + try: + staged.rename(PUBLIC_DIR) + except BaseException: + if had_previous: + backup.rename(PUBLIC_DIR) + raise + if had_previous: + _remove_path(backup) + + def local_resource_status() -> bool: """ 检查当前平台和 Python 版本所需的全部站点资源文件是否存在。 @@ -2852,7 +2868,31 @@ def _merge_notification_switches(existing_items: list[dict]) -> list[dict]: return [merged[key] for key in [*preferred_order, *extras]] +def _run_with_local_database_transactions(operation: Callable[[], None]) -> None: + """ + 为独立安装命令装配真实短事务,并在成功或异常后恢复原事务端口。 + + :param operation: 在当前命令事务端口已装配时执行的同步初始化操作 + """ + if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + from app.db.adapters.transaction import TransactionalWriteRunner + from app.db.session import SessionFactory, async_session_scope + from app.db.uow import transaction_runner_scope + + runner = TransactionalWriteRunner(sync_session=SessionFactory, async_session=async_session_scope) + with transaction_runner_scope(sync=runner.sync, async_=runner.async_): + operation() + + def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None: + """ + 在独立命令事务作用域内应用向导配置。 + """ + _run_with_local_database_transactions(lambda: _apply_local_system_config_transactional(config_payload)) + + +def _apply_local_system_config_transactional(config_payload: dict[str, Any]) -> None: """ 应用向导配置并确保本地媒体库路径存在,由下载器管理下载路径。 """ @@ -2870,10 +2910,7 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None: ) from app.application.configuration import SystemConfigService from app.application.directory import normalize_directory_system_config_value - from app.db.adapters.transaction import TransactionalWriteRunner from app.db.oper.systemconfig import SystemConfigOper - from app.db.session import SessionFactory, async_session_scope - from app.db.uow import configure_transaction_runners from app.schemas.category import ClassificationPolicyState from app.schemas.types import SystemConfigKey from app.startup.initializers.database import prepare_database @@ -2882,15 +2919,6 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None: "当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup" ) from exc - transaction_runner = TransactionalWriteRunner( - sync_session=SessionFactory, - async_session=async_session_scope, - ) - configure_transaction_runners( - sync=transaction_runner.sync, - async_=transaction_runner.async_, - ) - generated_password = None def prepare_superuser_password() -> None: @@ -3061,7 +3089,7 @@ def _ensure_superuser_account_inner() -> None: update_payload["hashed_password"] = get_password_hash(password) if update_payload: - user.update(user_oper._db, update_payload) + user_oper.update(user.id, update_payload) if password: print_step(f"已同步超级管理员账号与密码:{username}") else: @@ -3096,6 +3124,13 @@ def _prepare_superuser_password_for_bootstrap() -> Optional[str]: def _sync_superuser_account_inner() -> None: + """ + 在独立命令事务作用域内迁移数据库并同步管理员。 + """ + _run_with_local_database_transactions(_sync_superuser_account_transactional) + + +def _sync_superuser_account_transactional() -> None: """ 在数据库迁移前准备初始密码,迁移后同步管理员并展示新生成密码。 """ @@ -4482,36 +4517,20 @@ def run_agent_request( sys.path.insert(0, str(ROOT)) try: - from app.agent.orchestrator import MoviePilotAgent from app.runtime.config import settings - from app.startup.initializers.database import prepare_database + from app.startup.composition.cliagent import run_cli_agent_request except ModuleNotFoundError as exc: raise RuntimeError( "当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup" ) from exc - if not settings.AI_AGENT_ENABLE: - raise RuntimeError("MoviePilot 智能体未启用,请先在配置中打开 AI_AGENT_ENABLE") - - prepare_database() - session = (session_id or "").strip() if new_session or not session: session = f"cli-{uuid.uuid4().hex[:12]}" - async def _run_agent() -> dict[str, str]: - """ - 在当前会话执行智能体,抑制主动渠道回复并收集流式输出。 - """ - agent = MoviePilotAgent(session_id=session, user_id=user_id or "cli") - agent.suppress_user_reply = True - await agent.process(message.strip()) - return { - "session_id": session, - "result": (agent._streamed_output or "").strip(), - } - - return asyncio.run(_run_agent()) + return asyncio.run(run_cli_agent_request( + message=message.strip(), session_id=session, user_id=user_id or "cli", settings=settings, + )) def build_parser() -> argparse.ArgumentParser: diff --git a/scripts/perf/moviepilot_docker_ab.py b/scripts/perf/moviepilot_docker_ab.py index 049daa6eb..394066aee 100644 --- a/scripts/perf/moviepilot_docker_ab.py +++ b/scripts/perf/moviepilot_docker_ab.py @@ -704,7 +704,8 @@ def assert_owned(resource, args: argparse.Namespace, kind: str) -> None: """ 删除或复用资源前验证实验标签,避免误伤用户资源。 """ - resource_labels = resource.attrs.get("Labels") or {} + attributes = resource.attrs.get("Config", {}) if kind == "container" else resource.attrs + resource_labels = attributes.get("Labels") or {} if resource_labels.get(CAMPAIGN_LABEL) != args.campaign: raise HarnessError(f"拒绝操作非本 campaign 的 {kind}:{resource.name}") diff --git a/scripts/start-local.sh b/scripts/start-local.sh index e59adcbf2..994095545 100755 --- a/scripts/start-local.sh +++ b/scripts/start-local.sh @@ -6,6 +6,9 @@ SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" MOVIEPILOT_BIN="$PROJECT_ROOT/moviepilot" VENV_PYTHON="$PROJECT_ROOT/venv/bin/python" +if [[ -x "$PROJECT_ROOT/.venv/bin/python" ]]; then + VENV_PYTHON="$PROJECT_ROOT/.venv/bin/python" +fi show_usage() { cat <<'EOF' @@ -20,6 +23,11 @@ show_usage() { EOF } +if [[ "${1:-}" == "help" || "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_usage + exit 0 +fi + if [[ ! -x "$MOVIEPILOT_BIN" ]]; then printf '未找到本地 CLI:%s\n' "$MOVIEPILOT_BIN" >&2 exit 1 diff --git a/skills/anysearch/README.md b/skills/anysearch/README.md index 2b7dfe00c..3cc195608 100644 --- a/skills/anysearch/README.md +++ b/skills/anysearch/README.md @@ -38,6 +38,10 @@ unzip anysearch-skill.zip ## API Key Configuration +Missing option values and MCP tool errors exit with a nonzero status and write +the error to stderr. A successful HTTP response can still contain a tool error; +check the CLI exit status before using its output. + An API key is **optional but strongly recommended**. Without a key, you can still use all search features via anonymous access, but with **lower rate limits and quota**. ### How to configure diff --git a/skills/anysearch/SKILL.md b/skills/anysearch/SKILL.md index 6dd8147b9..dff4da0c1 100644 --- a/skills/anysearch/SKILL.md +++ b/skills/anysearch/SKILL.md @@ -1,7 +1,7 @@ --- name: anysearch description: Real-time search engine supporting web search, vertical domain search, parallel batch search, and URL content extraction. -version: 2 +version: 3 authors: - AnySearch Team credentials: diff --git a/skills/anysearch/scripts/anysearch_cli.js b/skills/anysearch/scripts/anysearch_cli.js index 06adc3c37..a656cff78 100644 --- a/skills/anysearch/scripts/anysearch_cli.js +++ b/skills/anysearch/scripts/anysearch_cli.js @@ -68,6 +68,13 @@ function httpRequest(url, payload, apikey) { return; } const content = json.result && json.result.content; + if (json.result && json.result.isError) { + const text = Array.isArray(content) + ? content.filter((item) => item.type === "text").map((item) => item.text).join("\n") + : ""; + reject(new Error(`Tool Error: ${text || "MCP tool failed"}`)); + return; + } if (Array.isArray(content)) { const textItem = content.find((c) => c.type === "text"); if (textItem) { diff --git a/skills/anysearch/scripts/anysearch_cli.ps1 b/skills/anysearch/scripts/anysearch_cli.ps1 index 235da53df..d25bf7118 100644 --- a/skills/anysearch/scripts/anysearch_cli.ps1 +++ b/skills/anysearch/scripts/anysearch_cli.ps1 @@ -97,6 +97,15 @@ function Call-Api { $result = $null try { $result = $resp.result } catch { $result = $resp } + $toolFailed = $false + try { $toolFailed = ($result.isError -eq $true) } catch { } + if ($toolFailed) { + $errMsg = ($result.content | Where-Object { $_.type -eq "text" } | ForEach-Object { $_.text }) -join "`n" + if (-not $errMsg) { $errMsg = "MCP tool failed" } + Write-Error "Tool Error: $errMsg" + exit 1 + } + if ($result -and $result.content) { foreach ($item in $result.content) { if ($item.type -eq "text") { diff --git a/skills/anysearch/scripts/anysearch_cli.py b/skills/anysearch/scripts/anysearch_cli.py index 2ffe414aa..33fb025b2 100644 --- a/skills/anysearch/scripts/anysearch_cli.py +++ b/skills/anysearch/scripts/anysearch_cli.py @@ -93,6 +93,10 @@ def _call_api(tool_name: str, arguments: dict, api_key: str) -> str: sys.exit(1) result = data.get("result", {}) content = result.get("content", []) + if result.get("isError"): + message = "\n".join(item.get("text", "") for item in content if item.get("type") == "text") + print(f"Tool Error: {message or json.dumps(result, ensure_ascii=False)}", file=sys.stderr) + sys.exit(1) for item in content: if item.get("type") == "text": return item.get("text", "") diff --git a/skills/anysearch/scripts/anysearch_cli.sh b/skills/anysearch/scripts/anysearch_cli.sh index e34eb19b9..19513dae3 100644 --- a/skills/anysearch/scripts/anysearch_cli.sh +++ b/skills/anysearch/scripts/anysearch_cli.sh @@ -30,6 +30,13 @@ _load_env API_KEY="${ANYSEARCH_API_KEY:-}" +_require_option_value() { + if [[ $# -lt 2 || -z "$2" || "$2" == -* ]]; then + echo "Error: $1 requires a value" >&2 + exit 1 + fi +} + # BEGIN GENERATED:CONSTANTS AVAILABLE_DOMAINS=("general" "resource" "social_media" "finance" "academic" "legal" "health" "business" "security" "ip" "code" "energy" "environment" "agriculture" "travel" "film" "gaming") # END GENERATED:CONSTANTS @@ -65,6 +72,12 @@ _call_api() { exit 1 fi + if printf '%s' "$response" | jq -e '.result.isError == true' >/dev/null 2>&1; then + error_msg=$(printf '%s' "$response" | jq -r '[.result.content[]? | select(.type == "text") | .text] | join("\n")') + echo "Tool Error: ${error_msg:-MCP tool failed}" >&2 + exit 1 + fi + local text_block text_block=$(printf '%s' "$response" | jq -r '.result.content[0].text // empty' 2>/dev/null) if [[ -n "$text_block" ]]; then @@ -82,6 +95,7 @@ _cmd_search() { local max_results="" while [[ $# -gt 0 ]]; do + [[ "$1" == -* ]] && _require_option_value "$@" case "$1" in --domain|-d) domain="$2"; shift 2 ;; --sub_domain|-s) sub_domain="$2"; shift 2 ;; @@ -126,6 +140,7 @@ _cmd_get_sub_domains() { local domains="" while [[ $# -gt 0 ]]; do + [[ "$1" == -* ]] && _require_option_value "$@" case "$1" in --domains) domains="$2"; shift 2 ;; --domain) domain="$2"; shift 2 ;; @@ -158,6 +173,7 @@ _cmd_extract() { local url="" while [[ $# -gt 0 ]]; do + [[ "$1" == -* ]] && _require_option_value "$@" case "$1" in --url|-u) url="$2"; shift 2 ;; --api_key) API_KEY="$2"; shift 2 ;; @@ -181,6 +197,7 @@ _cmd_batch_search() { local query_items=() while [[ $# -gt 0 ]]; do + [[ "$1" == -* ]] && _require_option_value "$@" case "$1" in --queries|-q) queries="$2"; shift 2 ;; --query) query_items+=("$2"); shift 2 ;; @@ -250,7 +267,7 @@ _cmd_doc() { domains=$(jq -r '.available_domains | join(" ")' "$shared/constants.json") tpl="${tpl//\{\{LANG_NAME\}\}/Bash}" tpl="${tpl//\{\{LANG_CODEBLOCK\}\}/bash}" - tpl="${tpl//\{\{LANG_INVOKE\}\}\}/bash scripts/anysearch_cli.sh}" + tpl="${tpl//\{\{LANG_INVOKE\}\}/bash scripts/anysearch_cli.sh}" tpl="${tpl//\{\{DOMAINS_SPACE\}\}/$domains}" printf '%s\n' "$tpl" } diff --git a/skills/anysearch/scripts/generate.py b/skills/anysearch/scripts/generate.py index e8833a0d0..b2c01c7ab 100644 --- a/skills/anysearch/scripts/generate.py +++ b/skills/anysearch/scripts/generate.py @@ -143,7 +143,7 @@ def render_doc_block(ext, constants): domains=$(jq -r '.available_domains | join(" ")' "$shared/constants.json") tpl="${tpl//\{\{LANG_NAME\}\}/Bash}" tpl="${tpl//\{\{LANG_CODEBLOCK\}\}/bash}" - tpl="${tpl//\{\{LANG_INVOKE\}\}\}/bash scripts/anysearch_cli.sh}" + tpl="${tpl//\{\{LANG_INVOKE\}\}/bash scripts/anysearch_cli.sh}" tpl="${tpl//\{\{DOMAINS_SPACE\}\}/$domains}" printf '%s\n' "$tpl" }''' diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index 613e51656..3007493c2 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -1,6 +1,6 @@ --- name: database-operation -version: 11 +version: 12 description: >- Use this skill when you need to inspect, query, maintain, or carefully modify the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper, @@ -117,6 +117,9 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - `write` executes data updates and schema-changing statements directly. - `query --write` remains available as a compatibility alias for write statements. - Multiple SQL statements in one invocation are rejected. +- Keywords and semicolons inside SQL literals, quoted identifiers, and comments + do not change a query into a write or a multi-statement request. Unclosed + lexical structures remain invalid. - Plain `SELECT` queries get a default `LIMIT 100` if no limit is present. - Query results are returned exactly as stored. The agent may use sensitive values internally when needed, but must not echo secrets in the final user-facing response unless the user explicitly asks to inspect that value. diff --git a/skills/database-operation/scripts/mp-db.py b/skills/database-operation/scripts/mp-db.py index 02e7a7fcf..e9faa49d1 100644 --- a/skills/database-operation/scripts/mp-db.py +++ b/skills/database-operation/scripts/mp-db.py @@ -224,23 +224,109 @@ def _build_engine() -> Engine: def _normalize_sql(sql: str) -> str: """ - 去除 SQL 首尾空白和末尾分号。 + 去除 SQL 首尾空白及注释前唯一的语句终止符,保留字面值内容。 """ - return sql.strip().rstrip(";") + sql = sql.strip() + code = _masked_sql(sql) + if code is not None and code.count(";") == 1 and code.rstrip().endswith(";"): + position = code.rfind(";") + return sql[:position] + sql[position + 1:] + return sql + + +def _quoted_end(sql: str, start: int, quote: str, *, escaped: bool = False) -> Optional[int]: + """ + 寻找带成对转义引号的词法项末尾;仅 PostgreSQL E 字符串解析反斜杠。 + """ + index = start + 1 + closing = "]" if quote == "[" else quote + while index < len(sql): + if escaped and sql[index] == "\\": + index += 2 + elif sql[index] == closing: + if quote != "[" and sql[index:index + 2] == closing * 2: + index += 2 + else: + return index + 1 + else: + index += 1 + return None + + +def _block_comment_end(sql: str, start: int) -> Optional[int]: + """ + 定位块注释末尾,同时支持 PostgreSQL 的嵌套注释。 + """ + depth = 1 + index = start + 2 + while index < len(sql) and depth: + if sql[index:index + 2] == "/*": + depth += 1 + index += 2 + elif sql[index:index + 2] == "*/": + depth -= 1 + index += 2 + else: + index += 1 + return index if depth == 0 else None + + +def _sql_literal_end(sql: str, index: int) -> Optional[int]: + """ + 返回引号或美元引号项的结束位置;不是词法项时返回当前位置。 + """ + char = sql[index] + if char in "'\"`[": + escaped = char == "'" and index > 0 and sql[index - 1] in "eE" and ( + index < 2 or not (sql[index - 2].isalnum() or sql[index - 2] == "_") + ) + return _quoted_end(sql, index, char, escaped=escaped) + if char == "$": + match = re.match(r"\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$", sql[index:]) + if match: + end = sql.find(match.group(), index + len(match.group())) + return end + len(match.group()) if end >= 0 else None + return index + + +def _masked_sql(sql: str) -> Optional[str]: + """ + 用等长空白遮蔽字面值、标识符与注释,未闭合结构按无效 SQL 拒绝。 + """ + code = list(sql) + index = 0 + while index < len(sql): + if sql[index:index + 2] == "--": + end = sql.find("\n", index) + end = len(sql) if end < 0 else end + elif sql[index:index + 2] == "/*": + end = _block_comment_end(sql, index) + else: + end = _sql_literal_end(sql, index) + if end is None: + return None + if end > index: + code[index:end] = " " * (end - index) + index = end + else: + index += 1 + return "".join(code) def _contains_multiple_statements(sql: str) -> bool: """ 判断 SQL 是否包含多语句分隔符。 """ - return ";" in sql + code = _masked_sql(sql) + return code is None or ";" in code.rstrip().removesuffix(";") def _is_write_statement(sql: str) -> bool: """ 判断 SQL 是否为写操作或结构变更操作。 """ - return bool(WRITE_STATEMENT_RE.match(sql)) + code = _masked_sql(sql) + return code is not None and bool(WRITE_STATEMENT_RE.match(code)) def _is_supported_statement(sql: str, allow_write: bool) -> bool: @@ -249,9 +335,12 @@ def _is_supported_statement(sql: str, allow_write: bool) -> bool: """ if _contains_multiple_statements(sql): return False + code = _masked_sql(sql) + if code is None or not code.strip().strip(";"): + return False if allow_write: return True - return bool(SELECT_STATEMENT_RE.match(sql)) and not WRITE_KEYWORD_RE.search(sql) + return bool(SELECT_STATEMENT_RE.match(code)) and not WRITE_KEYWORD_RE.search(code) def _append_limit(sql: str, limit: int) -> str: @@ -260,12 +349,12 @@ def _append_limit(sql: str, limit: int) -> str: """ if limit <= 0: return sql - lowered = sql.lower() + lowered = (_masked_sql(sql) or "").lower() if not lowered.lstrip().startswith("select"): return sql if re.search(r"\blimit\s+\d+\b", lowered): return sql - return f"{sql} LIMIT {limit}" + return f"{sql}\nLIMIT {limit}" def _row_to_dict(row: Any) -> dict[str, Any]: diff --git a/skills/feedback-issue/SKILL.md b/skills/feedback-issue/SKILL.md index 167e19776..371e26748 100644 --- a/skills/feedback-issue/SKILL.md +++ b/skills/feedback-issue/SKILL.md @@ -1,6 +1,6 @@ --- name: feedback-issue -version: 11 +version: 12 description: >- Use this skill ONLY when the user EXPLICITLY requests filing an upstream issue for MoviePilot core, frontend, or an installed plugin, diff --git a/skills/feedback-issue/scripts/collect_feedback_diagnostics.py b/skills/feedback-issue/scripts/collect_feedback_diagnostics.py index afd5b1de6..0c8af8d26 100644 --- a/skills/feedback-issue/scripts/collect_feedback_diagnostics.py +++ b/skills/feedback-issue/scripts/collect_feedback_diagnostics.py @@ -33,7 +33,8 @@ _MAX_TIME_WINDOW_MINUTES = 24 * 60 _LOG_TIMESTAMP_RE = re.compile(r"(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})") _LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" _LOG_MODULE_RE = re.compile( - r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+-\s+([^\s][^\-]*?)\s+-\s+" + r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+" + r"(?:\[[^\]\r\n]*\]\s+)?-\s+([^\s][^\-]*?)\s+-\s+" ) _LOG_DYNAMIC_VALUE_RE = re.compile( r"(?- Use this skill when the user asks to publish, upload, sync, pull, push, diff, or maintain a MoviePilot local plugin in a GitHub repository. Covers using the @@ -135,8 +135,13 @@ Options: - Do not log or print GitHub token values. - For push operations, report created, updated, deleted, skipped, and rejected files separately. -- For pull operations, preserve local-only ignored files and refuse to overwrite - differing local files unless `--force` is used. +- For pull operations, preserve excluded local files even with `--force`. + Invalid local package JSON stops the pull before any local file is written, + including in `--force` mode. + Differing local plugin files or package metadata stop the entire pull unless + `--force` is used. Package entries for other plugins remain untouched. +- With the `auto` layout, inspect each configured local repository separately; + the working directory does not determine the layout of other repositories. ## Examples diff --git a/skills/publish-moviepilot-plugin/scripts/publish_plugin.py b/skills/publish-moviepilot-plugin/scripts/publish_plugin.py index b2ddcb2ee..e7c2bdcd7 100644 --- a/skills/publish-moviepilot-plugin/scripts/publish_plugin.py +++ b/skills/publish-moviepilot-plugin/scripts/publish_plugin.py @@ -691,7 +691,6 @@ def resolve_local_repo(local_repo: str, plugin_id: str, package_version: str) -> return Path(local_repo).expanduser().resolve() settings = load_moviepilot_settings() raw_paths = str(getattr(settings, "PLUGIN_LOCAL_REPO_PATHS", "") or "") if settings else "" - layout = resolve_layout(package_version, Path.cwd(), plugin_id) matches: list[Path] = [] for item in raw_paths.split(","): if not item.strip(): @@ -700,6 +699,7 @@ def resolve_local_repo(local_repo: str, plugin_id: str, package_version: str) -> if not path.is_absolute() and settings: path = Path(getattr(settings, "ROOT_PATH")) / path path = path.resolve() + layout = resolve_layout(package_version, path, plugin_id) if plugin_dir(path, layout, plugin_id).exists(): matches.append(path) if len(matches) == 1: @@ -934,6 +934,8 @@ def build_pull_changes( """ changes = ChangeSet(rejected=rejected) for path, remote_state in remote_files.items(): + if path in rejected: + continue local_state = local_files.get(path) if local_state is None: changes.create.append(path) @@ -1132,6 +1134,24 @@ def create_repo(args: argparse.Namespace) -> dict[str, Any]: } +def _read_local_pull_package(local_repo: Path, layout: Layout) -> dict[str, Any]: + """ + 拉取写入前验证本地package整体结构,保留其它插件的元数据。 + + :param local_repo: 本地插件仓库目录 + :param layout: 当前仓库布局及package文件名 + :return: 完整package对象;文件不存在时返回空对象 + :raises ValueError: 现有文件不是有效JSON对象,不能安全合并 + """ + package_path = local_repo / layout.package_file + if not package_path.exists(): + return {} + package_data = json.loads(package_path.read_text(encoding="utf-8")) + if not isinstance(package_data, dict): + raise ValueError(f"本地 package 文件不是 JSON 对象:{package_path}") + return package_data + + def pull(args: argparse.Namespace) -> dict[str, Any]: """ 从 GitHub 拉取插件文件到本地仓库。 @@ -1140,6 +1160,8 @@ def pull(args: argparse.Namespace) -> dict[str, Any]: :return: 拉取结果 """ context = build_context(args) + local_package = _read_local_pull_package(context["local_repo"], context["layout"]) + local_entry = local_package.get(context["plugin_id"]) client: GitHubClient = context["client"] local_files, rejected = collect_local_files( context["local_repo"], @@ -1157,16 +1179,9 @@ def pull(args: argparse.Namespace) -> dict[str, Any]: package_data = json.loads(package_file.content.decode("utf-8")) if isinstance(package_data, dict): package_entry = package_data.get(context["plugin_id"]) - local_entry = None - try: - local_entry = read_package_entry( - context["local_repo"], - context["layout"], - context["plugin_id"], - ) - except (FileNotFoundError, ValueError, json.JSONDecodeError): - local_entry = None package_change = "same" if package_entry == local_entry else "update" + if package_entry is not None and local_entry is not None and package_change == "update" and not args.force: + changes.conflicts.append(context["layout"].package_file) payload = result_payload(context, changes, package_change=package_change) if changes.conflicts and not args.force: payload["message"] = "本地存在未发布改动,已拒绝覆盖。确认后可使用 --force。" diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index a42cb1894..94fd3e056 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -640,6 +640,13 @@ }, "target": "asyncio.to_thread" }, + "app/startup/composition/cliagent.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "_close_cli_extensions": 3, + "run_cli_agent_request": 2 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, "app/startup/composition/network.py:asyncio.to_thread": { "owners": { "_NetworkTestTransportAdapter.websocket": 1 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index ac1da1a0e..e9ba119d1 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -961,8 +961,8 @@ "runtime_only": true } }, - "edge_count": 9649, - "edge_sha256": "3038db090f87166a7cae3e63e78e30def7d3466252c846bdab3d6996a8f65999", + "edge_count": 9690, + "edge_sha256": "2acb67e3a6c9e7c10938a8dfde7571359a644b174e8ac2505f71fd1202976633", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -9760,6 +9760,47 @@ "app.startup.composition.classification -> app.schemas", "app.startup.composition.classification -> app.schemas.category", "app.startup.composition.classification -> app.schemas.types", + "app.startup.composition.cliagent -> app.adapters", + "app.startup.composition.cliagent -> app.adapters.network", + "app.startup.composition.cliagent -> app.adapters.network.http", + "app.startup.composition.cliagent -> app.agent", + "app.startup.composition.cliagent -> app.agent.loader", + "app.startup.composition.cliagent -> app.db", + "app.startup.composition.cliagent -> app.db.session", + "app.startup.composition.cliagent -> app.runtime", + "app.startup.composition.cliagent -> app.runtime.events", + "app.startup.composition.cliagent -> app.runtime.execution", + "app.startup.composition.cliagent -> app.runtime.extensions", + "app.startup.composition.cliagent -> app.runtime.extensions.module", + "app.startup.composition.cliagent -> app.runtime.extensions.module.manager", + "app.startup.composition.cliagent -> app.runtime.extensions.plugin", + "app.startup.composition.cliagent -> app.runtime.extensions.plugin.manager", + "app.startup.composition.cliagent -> app.runtime.loop", + "app.startup.composition.cliagent -> app.runtime.tasks", + "app.startup.composition.cliagent -> app.runtime.thread", + "app.startup.composition.cliagent -> app.schemas", + "app.startup.composition.cliagent -> app.schemas.types", + "app.startup.composition.cliagent -> app.startup", + "app.startup.composition.cliagent -> app.startup.composition", + "app.startup.composition.cliagent -> app.startup.composition.agent", + "app.startup.composition.cliagent -> app.startup.composition.chain", + "app.startup.composition.cliagent -> app.startup.composition.classification", + "app.startup.composition.cliagent -> app.startup.composition.configuration", + "app.startup.composition.cliagent -> app.startup.composition.database", + "app.startup.composition.cliagent -> app.startup.composition.network", + "app.startup.composition.cliagent -> app.startup.composition.runtime", + "app.startup.composition.cliagent -> app.startup.composition.subscription", + "app.startup.composition.cliagent -> app.startup.composition.system", + "app.startup.composition.cliagent -> app.startup.initializers", + "app.startup.composition.cliagent -> app.startup.initializers.agent", + "app.startup.composition.cliagent -> app.startup.initializers.cache", + "app.startup.composition.cliagent -> app.startup.initializers.chain", + "app.startup.composition.cliagent -> app.startup.initializers.database", + "app.startup.composition.cliagent -> app.startup.initializers.domain", + "app.startup.composition.cliagent -> app.startup.initializers.modules", + "app.startup.composition.cliagent -> app.startup.initializers.network", + "app.startup.composition.cliagent -> app.startup.initializers.plugins", + "app.startup.composition.cliagent -> app.startup.initializers.site", "app.startup.composition.configuration -> app.application", "app.startup.composition.configuration -> app.application.configuration", "app.startup.composition.configuration -> app.application.database", @@ -10614,7 +10655,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1128, + "module_count": 1129, "modules": [ "app", "app.adapters", @@ -11689,6 +11730,7 @@ "app.startup.composition.cache", "app.startup.composition.chain", "app.startup.composition.classification", + "app.startup.composition.cliagent", "app.startup.composition.configuration", "app.startup.composition.context", "app.startup.composition.database", diff --git a/tests/test_agent_skill_docs_generator.py b/tests/test_agent_skill_docs_generator.py index 1505d4915..0818ed12f 100644 --- a/tests/test_agent_skill_docs_generator.py +++ b/tests/test_agent_skill_docs_generator.py @@ -84,6 +84,9 @@ def test_full_regeneration_is_current_and_idempotent( path.relative_to(tmp_path): path.read_bytes() for path in (tmp_path / "skills").rglob("*.md") } + database_skill = tmp_path / "skills/database-operation/SKILL.md" + database_skill.write_text(re.sub(r"(?m)^version: \d+$", "version: 11", + database_skill.read_text(encoding="utf-8"), count=1), encoding="utf-8") for _ in range(2): assert generator.main() == 0 diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 10e9545b5..a11455830 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -127,8 +127,9 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: expected_versions = { "browser-use": "4", "command-dispatch": "2", - "database-operation": "11", - "feedback-issue": "11", + "database-operation": "12", + "feedback-issue": "12", + "anysearch": "3", "moviepilot-api": "59", "moviepilot-update": "7", "organize-files": "5", @@ -136,7 +137,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "generate-identifiers": "4", "create-moviepilot-plugin": "7", "create-moviepilot-skill": "3", - "publish-moviepilot-plugin": "3", + "publish-moviepilot-plugin": "4", "downloader-operation": "3", "mediaserver-operation": "3", } diff --git a/tests/test_cli_agent_review_runtime.py b/tests/test_cli_agent_review_runtime.py new file mode 100644 index 000000000..fa87f6c19 --- /dev/null +++ b/tests/test_cli_agent_review_runtime.py @@ -0,0 +1,199 @@ +""" +命令行 Agent 正式组合根、持久化和资源收口的隔离进程回归。 +""" + +import os +import subprocess +import sys +from pathlib import Path + +import coverage +import pytest + + +@pytest.mark.parametrize("failure", ["", "model", "configuration", "cancel"]) +def test_cli_agent_owns_real_runtime_and_closes_resources(tmp_path, failure): + """ + 临时 SQLite 和真实 Agent 入口只替换模型图,成功及失败后均释放运行时 owner。 + """ + code = r''' +import asyncio +import json +import os +from types import SimpleNamespace + +if os.environ.get("CLI_REVIEW_COVERAGE_FILE"): + import atexit + import coverage + measurement = coverage.Coverage( + data_file=os.environ["CLI_REVIEW_COVERAGE_FILE"], + data_suffix=True, + config_file=os.environ["CLI_REVIEW_COVERAGE_CONFIG"], + source=["app"], + branch=True, + ) + measurement.start() + + def save_coverage(): + """ + 子进程退出时保存真实执行轨迹,唯一后缀由父pytest-cov统一合并。 + """ + measurement.stop() + measurement.save() + + atexit.register(save_coverage) + +from app.testing.bootstrap import ensure_sites_stub, isolate_config_dir + +isolate_config_dir() +ensure_sites_stub() +import pytest +from app.testing.network import block_real_network +network_patch = pytest.MonkeyPatch() +network_guard = block_real_network.__wrapped__(network_patch) +next(network_guard) +from app.startup.initializers.cache import configure_cache_dependencies +configure_cache_dependencies() + +from app.runtime.config import settings +settings.AI_AGENT_ENABLE = True +settings.LLM_PROVIDER = "openai" +settings.LLM_MODEL = "review" +settings.LLM_API_KEY = "review-not-a-secret" + +from langchain_core.messages import AIMessage +from app.agent.loader import get_agent_manager, get_running_agent_manager +from app.agent.orchestrator import MoviePilotAgent +from app.application.configuration import get_api_runtime_config_snapshot +from app.application.chain.context import get_chain_runtime_context +from app.application.messaging.chat import get_configured_agent_chat_persistence +from app.db.oper.agentchat import AgentChatOper +from app.db import session as database_session +from app.db.session import SessionFactory +from app.runtime.loop import main_loop_registry +from app.startup.composition import configuration +from app.startup.composition.database import database_runtime_active +from scripts.local_setup import run_agent_request + +seen = [] +closed = [] +failure = os.environ["CLI_REVIEW_FAILURE"] +original_close_database = database_session.close_database + +async def observe_database_close(): + """ + 在真实引擎释放前读取持久消息,观察后继续调用实际关闭 owner。 + """ + with SessionFactory() as session: + record = AgentChatOper(session).get("cli-review", "review") + closed.append(bool(record and record.agent_messages)) + await original_close_database() + +database_session.close_database = observe_database_close + +async def graph(agent, streaming=False): + """ + 模型边界核对真实组合根身份,并返回确定的 LangChain 消息。 + """ + del streaming + manager = get_running_agent_manager() + seen.append({ + "data": agent._data is not None, + "canonical": manager is get_agent_manager(), + "memory": manager is not None and agent._memory is manager._memory, + "worker": database_runtime_active(), + "configuration": get_api_runtime_config_snapshot().ai_agent_enable, + "persistence": agent._data is not None and agent._data.chat_persistence is get_configured_agent_chat_persistence(), + "loop": main_loop_registry.require() is asyncio.get_running_loop(), + "chain": get_chain_runtime_context().configuration.ai_agent_enable, + }) + agent._last_agent_cache_hit = False + state = {} + + async def invoke(payload, config): + del config + if failure == "model": + raise RuntimeError("review model failure") + if failure == "cancel": + raise asyncio.CancelledError() + if os.environ.get("CLI_REVIEW_HISTORY"): + assert len(payload["messages"]) >= 3, payload + state["messages"] = [*payload["messages"], AIMessage(content="review answer")] + return state + + return SimpleNamespace(ainvoke=invoke, get_state=lambda _config: SimpleNamespace(values=state)) + +async def no_title(_self, _message): + """ + 标题生成同样属于模型边界,本回归只验证主对话。 + """ + +MoviePilotAgent._create_agent = graph +MoviePilotAgent.prepare_chat_title = no_title +MoviePilotAgent._should_stream = lambda _self: False +MoviePilotAgent._learning_enabled = lambda _self: False +if failure == "configuration": + async def fail_configuration(**_kwargs): + raise RuntimeError("review configuration failure") + configuration.compose_configuration = fail_configuration + +try: + result = run_agent_request(message=" review prompt ", session_id="cli-review", new_session=False, user_id="review") +except RuntimeError as error: + assert failure == "configuration" and "review configuration failure" in str(error), str(error) +except asyncio.CancelledError: + assert failure == "cancel" +else: + assert failure != "configuration", "正式配置组合根没有执行" + assert len(seen) == 1 and all(seen[0].values()), seen + if not failure: + assert result == {"session_id":"cli-review", "result":"review answer"}, result + assert closed == [True], "CLI 对话未提交到临时 SQLite" + else: + assert result["result"], result + +assert len(closed) == 1, "引擎关闭 owner 没有执行" +assert not database_runtime_active(), "数据库 worker 未关闭" +assert get_running_agent_manager() is None, "Agent 能力仍处于运行态" +assert main_loop_registry.current is None, "CLI 主循环登记未释放" +if seen: + from app.agent.manager import agent_manager + assert not agent_manager._accepting_tasks and not agent_manager.active_agents + assert agent_manager._idle_cleanup_task is None + assert agent_manager._memory.cleanup_task is None +try: + next(network_guard) +except StopIteration: + pass +network_patch.undo() +print("CLI_REVIEW_OK") +''' + environment = {**os.environ, "CONFIG_DIR": str(tmp_path / "config"), "CLI_REVIEW_FAILURE": failure} + environment.pop("DEBUG", None) + active_coverage = coverage.Coverage.current() + if active_coverage is not None: + environment["CLI_REVIEW_COVERAGE_FILE"] = active_coverage.get_data().data_filename() + environment["CLI_REVIEW_COVERAGE_CONFIG"] = str(Path(__file__).parents[1] / ".coveragerc") + completed = subprocess.run( + [sys.executable, "-c", code], + cwd=Path(__file__).parents[1], + env=environment, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "CLI_REVIEW_OK" in completed.stdout + if not failure: + replay = subprocess.run( + [sys.executable, "-c", code], + cwd=Path(__file__).parents[1], + env={**environment, "CLI_REVIEW_HISTORY": "1"}, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert "CLI_REVIEW_OK" in replay.stdout diff --git a/tests/test_dev_skills_review_boundaries.py b/tests/test_dev_skills_review_boundaries.py new file mode 100644 index 000000000..a497bf536 --- /dev/null +++ b/tests/test_dev_skills_review_boundaries.py @@ -0,0 +1,66 @@ +""" +独立验证插件拉取预检、SQL词法和前端发布失败时的数据边界。 +""" + +import json +from argparse import Namespace +from types import SimpleNamespace + +import pytest + +from tests.test_dev_skills_review_regressions import _load_script + + +def _pull_context(tmp_path, monkeypatch): + """ + 准备真实临时插件目录,仅替换远端读取边界。 + """ + module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch) + directory = tmp_path / "plugins.v2/myplugin" + directory.mkdir(parents=True) + (directory / "__init__.py").write_text("# 旧本地源码\n") + package = tmp_path / "package.v2.json" + prefix = "plugins.v2/myplugin" + remote = {f"{prefix}/__init__.py": module.FileState(f"{prefix}/__init__.py", (directory / "__init__.py").read_bytes()), + f"{prefix}/new.py": module.FileState(f"{prefix}/new.py", b"# new")} + context = {"repo": "fixture/review", "branch": "v3", "plugin_id": "MyPlugin", "local_repo": tmp_path, + "layout": module.Layout("package.v2.json", "plugins.v2"), "remote_prefix": prefix, + "client": SimpleNamespace(list_files=lambda _prefix: remote, get_file=lambda path: module.FileState( + path, json.dumps({"MyPlugin": {"version": "1.0.0"}}).encode())), + "excludes": list(module.DEFAULT_EXCLUDES), "includes": []} + monkeypatch.setattr(module, "build_context", lambda _args: context) + return module, directory, package, context + + +@pytest.mark.parametrize("package_content", ["{broken", "[]"]) +@pytest.mark.parametrize("force", [False, True]) +def test_pull_invalid_local_package_never_partially_writes_plugin(tmp_path, monkeypatch, package_content, force): + """ + 本地package无效必须在任何文件写入前拒绝,强制模式也不能先创建文件再失败。 + """ + module, directory, package, context = _pull_context(tmp_path, monkeypatch) + package.write_text(package_content) + try: + result = module.pull(Namespace(force=force, dry_run=False)) + except ValueError: + pass + else: + assert context["layout"].package_file in result["changes"]["conflicts"] + assert not (directory / "new.py").exists() + assert package.read_text() == package_content + + +@pytest.mark.parametrize("existing_package", [False, True]) +def test_pull_valid_missing_entry_preserves_other_plugin_metadata(tmp_path, monkeypatch, existing_package): + """ + 缺少本地package或目标条目仍可正常拉取,新条目不能覆盖其它插件的元数据。 + """ + module, directory, package, _ = _pull_context(tmp_path, monkeypatch) + other = {"Other": {"version": "3.0.0", "name": "其它插件"}} + if existing_package: + package.write_text(json.dumps(other)) + result = module.pull(Namespace(force=False, dry_run=False)) + assert not result["changes"]["conflicts"] + assert (directory / "new.py").read_bytes() == b"# new" + expected = {**(other if existing_package else {}), "MyPlugin": {"version": "1.0.0"}} + assert json.loads(package.read_text()) == expected diff --git a/tests/test_dev_skills_review_regressions.py b/tests/test_dev_skills_review_regressions.py new file mode 100644 index 000000000..f3ceafba9 --- /dev/null +++ b/tests/test_dev_skills_review_regressions.py @@ -0,0 +1,284 @@ +""" +本地开发、审查工具和技能脚本的隔离回归。 +""" + +import ast +import importlib.util +import json +import os +import shutil +import subprocess +import sys +from argparse import Namespace +from pathlib import Path +from types import SimpleNamespace + +import pytest +from sqlalchemy import create_engine + +from scripts.architecture import baseline, coverage_ratchet +from scripts.evaluation.score import evaluate +from scripts.evaluation.world import EvaluationWorld +from scripts.perf import moviepilot_docker_ab as perf + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_script(relative, monkeypatch): + """ + 加载当前工作树脚本并在结束时恢复导入登记,不执行命令行主函数。 + """ + path = ROOT / relative + spec = importlib.util.spec_from_file_location("review_" + path.stem.replace("-", "_"), path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, spec.name, module) + spec.loader.exec_module(module) + return module + + +def test_coverage_write_snapshot_is_internally_consistent(tmp_path, monkeypatch) -> None: + """ + 相同比例但语句数变化的合法更新,逐文件与汇总必须使用同一份当前计数。 + """ + path = tmp_path / "baseline.json" + report = tmp_path / "coverage.json" + path.write_text(json.dumps(coverage_ratchet._snapshot_from_files({"app/demo.py": coverage_ratchet._metric(10, 8)}))) + report.write_text(json.dumps({"files": {"app/demo.py": {"summary": {"num_statements": 20, "covered_lines": 16}}}})) + monkeypatch.setattr(sys, "argv", ["coverage", "--write", "--report", str(report), "--baseline", str(path)]) + assert coverage_ratchet.main() == 0 + written = json.loads(path.read_text()) + assert coverage_ratchet.validate_coverage(written) == [] + assert written["files"]["app/demo.py"]["statements"] == 20 + + +@pytest.mark.parametrize("guard", ["TYPE_CHECKING", "typing.TYPE_CHECKING"]) +def test_type_checking_else_imports_are_runtime_dependencies(tmp_path, guard) -> None: + """ + 仅类型分支排除,else 内实际运行的依赖仍纳入架构检查。 + """ + file = tmp_path / "branch.py" + file.write_text(f"if {guard}:\n from app.adapters.system.host import SystemUtils\nelse:\n from app.adapters.network.http import RequestUtils\n") + imports = list(baseline.iter_runtime_import_nodes(ast.parse(file.read_text()))) + assert [node.module for node in imports] == ["app.adapters.network.http"] + + +@pytest.mark.parametrize("pages", [(6, 5, 4, 3, 2, 1), (1, 2, 3, 4, 5, 6, 1)]) +def test_long_context_score_rejects_reordered_or_repeated_pages(pages) -> None: + """ + 分页证据不能丢失读取顺序和重复次数。 + """ + world = EvaluationWorld("long_context") + for page in pages: + assert world.execute("subscription.list", query={"page": page, "count": 20})["success"] + report = {"status": "completed", "completed": ["subscription"], "unresolved": [], + "subscription_ids": [9001], "download_ids": [], "enabled_site_ids": []} + assert evaluate(world, report).to_dict()["passed"] is False + + +def test_perf_container_reads_config_labels() -> None: + """ + 同 campaign 容器的 Docker inspect 标签位于 Config 下,可受控复用。 + """ + container = SimpleNamespace(name="isolated-review", attrs={"Config": {"Labels": {perf.CAMPAIGN_LABEL: "review"}}}) + perf.assert_owned(container, Namespace(campaign="review"), "container") + + +@pytest.mark.parametrize("command,flag", [("search", "--domain"), ("get_sub_domains", "--domain"), + ("extract", "--url"), ("batch_search", "--query")]) +def test_anysearch_missing_flag_value_exits_without_loop(command, flag) -> None: + """ + 缺少参数值应立即错误退出,不启动请求或持续占用 CPU。 + """ + result = subprocess.run(["bash", str(ROOT / "skills/anysearch/scripts/anysearch_cli.sh"), command, flag], + capture_output=True, text=True, timeout=2) + assert result.returncode != 0 + assert result.stderr.strip() + + +def test_anysearch_help_resolves_invocation_template() -> None: + """ + 离线帮助包含可直接执行的 Bash 命令。 + """ + result = subprocess.run(["bash", str(ROOT / "skills/anysearch/scripts/anysearch_cli.sh"), "--help"], + capture_output=True, text=True, timeout=2) + assert result.returncode == 0 + assert "{{LANG_INVOKE}}" not in result.stdout + assert "bash scripts/anysearch_cli.sh" in result.stdout + + +def test_anysearch_propagates_mcp_tool_error(tmp_path) -> None: + """ + HTTP 成功但 MCP isError 时,CLI 以失败退出并说明工具错误。 + """ + (tmp_path / "requests.py").write_text( + "from types import SimpleNamespace\n" + "def post(*args, **kwargs):\n" + " return SimpleNamespace(raise_for_status=lambda: None, json=lambda: " + "{'result': {'isError': True, 'content': [{'type': 'text', 'text': 'fixture tool failure'}]}})\n" + ) + result = subprocess.run([sys.executable, str(ROOT / "skills/anysearch/scripts/anysearch_cli.py"), + "search", "review"], env={**os.environ, "PYTHONPATH": str(tmp_path)}, + capture_output=True, text=True, timeout=5) + assert result.returncode != 0 + assert "fixture tool failure" in result.stderr + + +def test_anysearch_bash_propagates_mcp_tool_error(tmp_path): + """ + Bash 入口也把HTTP成功的工具失败作为错误退出,curl边界固定为本地响应。 + """ + curl = tmp_path / "curl" + curl.write_text("#!/bin/bash\nprintf '%s\\n' '{\"result\":{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"fixture tool failure\"}]}}'\n") + curl.chmod(0o755) + result = subprocess.run(["bash", str(ROOT / "skills/anysearch/scripts/anysearch_cli.sh"), "search", "review"], + env={**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"]}, + capture_output=True, text=True, timeout=5) + assert result.returncode != 0 + assert "fixture tool failure" in result.stderr + + +def test_anysearch_node_propagates_mcp_tool_error(): + """ + Node CLI只替换HTTPS进程边界,工具错误不能返回成功状态。 + """ + node = shutil.which("node") + if node is None: + pytest.skip("Node runtime is unavailable") + path = str(ROOT / "skills/anysearch/scripts/anysearch_cli.js") + source = ''' +const https = require('https'); +const { EventEmitter } = require('events'); +https.request = (_options, callback) => { + const request = new EventEmitter(); + request.setTimeout = () => {}; + request.write = () => {}; + request.end = () => { + const response = new EventEmitter(); + response.statusCode = 200; + callback(response); + response.emit('data', JSON.stringify({result: {isError: true, content: [{type: 'text', text: 'fixture tool failure'}]}})); + response.emit('end'); + }; + return request; +}; +''' + "process.argv = ['node', " + json.dumps(path) + ", 'search', 'review'];\nrequire(" + json.dumps(path) + ");\n" + result = subprocess.run([node, "-e", source], capture_output=True, text=True, timeout=5) + assert result.returncode != 0 + assert "fixture tool failure" in result.stderr + + +@pytest.mark.parametrize("sql", ["SELECT 'update' AS action", "SELECT 'chapter; title' AS title", + "SELECT 1 /* update; ignored */", "SELECT 'it''s delete' AS text"]) +def test_readonly_sql_ignores_literals_and_comments(monkeypatch, sql) -> None: + """ + 字符串和注释内的写关键词或分号不能提高普通查询所需权限。 + """ + module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch) + assert module._is_supported_statement(sql, False) + + +@pytest.mark.parametrize("sql", ["DELETE FROM sample", "WITH removed AS (DELETE FROM sample RETURNING *) SELECT * FROM removed", + "SELECT 1; DELETE FROM sample", "SELECT 'unclosed", "SELECT 1 /* unclosed", + "SELECT 1; SELECT 2", "SELECT 1;;", "/* read */ UPDATE sample SET value=1"]) +def test_readonly_sql_still_rejects_writes_or_invalid_statements(monkeypatch, sql): + """ + 忽略字面值后仍拒绝真实写入、多语句及未闭合词法结构。 + """ + module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch) + assert not module._is_supported_statement(sql, False) + + +def test_readonly_sql_executes_one_real_query_and_rejects_mutation(tmp_path, monkeypatch, capsys): + """ + 实际 SQLite 查询保留字面值并正确终止注释,拒绝写入后数据仍完整。 + """ + module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch) + engine = create_engine("sqlite:///" + str(tmp_path / "review.db")) + monkeypatch.setattr(module, "_build_engine", lambda: engine) + try: + assert module.run_query("SELECT 'chapter; update' AS title; -- fixture comment") == 0 + assert json.loads(capsys.readouterr().out)["rows"] == [{"title": "chapter; update"}] + assert module.run_query("CREATE TABLE sample (id INTEGER)") == 1 + assert "require --write" in capsys.readouterr().err + assert module.run_query("SELECT 1; CREATE TABLE sample (id INTEGER)", allow_write=True) == 1 + finally: + engine.dispose() + + +def test_readonly_query_literal_limit_does_not_bypass_row_bound(tmp_path, monkeypatch, capsys): + """ + 字面值与注释内的LIMIT不影响实际查询默认行数限制。 + """ + module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch) + engine = create_engine("sqlite:///" + str(tmp_path / "bounded.db")) + monkeypatch.setattr(module, "_build_engine", lambda: engine) + try: + assert module.run_query("/* review */ SELECT 'limit 999' AS value UNION ALL SELECT 'second'", limit=1) == 0 + assert json.loads(capsys.readouterr().out)["row_count"] == 1 + finally: + engine.dispose() + + +@pytest.mark.parametrize("force", [False, True]) +def test_plugin_pull_protects_excluded_local_file(tmp_path, monkeypatch, force) -> None: + """ + 用户排除的本地路径不受远端同路径写入覆盖,即便强制拉取也保留。 + """ + module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch) + directory = tmp_path / "plugins.v2/myplugin" + directory.mkdir(parents=True) + (directory / "__init__.py").write_text("# review\n") + local = directory / ".env" + local.write_text("local-fixture\n") + prefix = "plugins.v2/myplugin" + remote = {f"{prefix}/__init__.py": module.FileState(f"{prefix}/__init__.py", b"# review\n"), + f"{prefix}/.env": module.FileState(f"{prefix}/.env", b"remote-fixture\n")} + context = {"repo": "fixture/review", "branch": "v3", "plugin_id": "MyPlugin", "local_repo": tmp_path, + "layout": module.Layout("package.v2.json", "plugins.v2"), "remote_prefix": prefix, + "client": SimpleNamespace(list_files=lambda _prefix: remote, get_file=lambda _path: None), + "excludes": list(module.DEFAULT_EXCLUDES), "includes": []} + monkeypatch.setattr(module, "build_context", lambda _args: context) + result = module.pull(Namespace(force=force, dry_run=False)) + assert local.read_text() == "local-fixture\n" + assert f"{prefix}/.env" in result["changes"]["rejected"] + assert f"{prefix}/.env" not in result["changes"]["create"] + + +def test_plugin_pull_preserves_unpublished_package_entry(tmp_path, monkeypatch) -> None: + """ + 未强制拉取不能覆盖待发布的插件 package 元数据。 + """ + module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch) + directory = tmp_path / "plugins.v2/myplugin" + directory.mkdir(parents=True) + (directory / "__init__.py").write_text("# review\n") + package = tmp_path / "package.v2.json" + original = {"MyPlugin": {"version": "2.0.0"}, "Other": {"version": "3.0.0"}} + package.write_text(json.dumps(original)) + prefix = "plugins.v2/myplugin" + remote = {f"{prefix}/__init__.py": module.FileState(f"{prefix}/__init__.py", b"# review\n")} + context = {"repo": "fixture/review", "branch": "v3", "plugin_id": "MyPlugin", "local_repo": tmp_path, + "layout": module.Layout("package.v2.json", "plugins.v2"), "remote_prefix": prefix, + "client": SimpleNamespace(list_files=lambda _prefix: remote, get_file=lambda path: module.FileState( + path, json.dumps({"MyPlugin": {"version": "1.0.0"}}).encode())), + "excludes": list(module.DEFAULT_EXCLUDES), "includes": []} + monkeypatch.setattr(module, "build_context", lambda _args: context) + result = module.pull(Namespace(force=False, dry_run=False)) + assert result["changes"]["conflicts"] + assert json.loads(package.read_text()) == original + + +def test_plugin_discovery_resolves_each_candidate_layout(tmp_path, monkeypatch) -> None: + """ + auto 布局按候选仓实际文件判断,工作目录不能决定全部仓库布局。 + """ + module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch) + legacy = tmp_path / "legacy" + (legacy / "plugins/myplugin").mkdir(parents=True) + (legacy / "package.json").write_text('{"MyPlugin": {"version": "1.0.0"}}') + empty = tmp_path / "backend" + empty.mkdir() + monkeypatch.chdir(empty) + monkeypatch.setattr(module, "load_moviepilot_settings", lambda: SimpleNamespace( + PLUGIN_LOCAL_REPO_PATHS=str(legacy), ROOT_PATH=tmp_path)) + assert module.resolve_local_repo("", "MyPlugin", "auto") == legacy diff --git a/tests/test_feedback_issue_log_quality.py b/tests/test_feedback_issue_log_quality.py index a72676444..b9a290e4e 100644 --- a/tests/test_feedback_issue_log_quality.py +++ b/tests/test_feedback_issue_log_quality.py @@ -80,6 +80,22 @@ def test_filter_lines_compacts_consecutive_repeated_templates(feedback_modules): assert matched_keywords == ["等待转存"] +def test_filter_lines_excludes_current_correlation_id_noise(feedback_modules): + """ + 当前带关联 ID 的 Agent 日志不能挤占有限的真实故障日志预算。 + """ + _, collect = feedback_modules + now = datetime.now() + timestamp = now.strftime("%Y-%m-%d %H:%M:%S") + real = f"【ERROR】{timestamp},001 [fixture-id] - transfer.py - review fixture failure" + noise = f"【ERROR】{timestamp},002 [fixture-id] - message.py - review fixture failure" + lines, _ = collect.filter_lines( + real + "\n" + noise, keywords=["fixture"], max_lines=1, + window_start=now - timedelta(minutes=5), + ) + assert lines == [real] + + def test_doctor_summary_groups_legacy_duplicate_advisories(feedback_modules): """ 旧版 Doctor 的重复插件发现也应在反馈摘要中合并展示。 diff --git a/tests/test_local_setup_frontend_version.py b/tests/test_local_setup_frontend_version.py index 17dc83cb5..9be8520c0 100644 --- a/tests/test_local_setup_frontend_version.py +++ b/tests/test_local_setup_frontend_version.py @@ -230,11 +230,11 @@ def test_update_reinstalls_repacked_frontend_in_dev_mode( archive.writestr("dist/version.txt", "v3.0.11") archive.writestr("dist/index.html", f"build-{len(downloads)}") - def install_runtime_deps(*_args, **_kwargs): + def install_runtime_deps(*_args, **kwargs): """ 恢复已安装依赖标记,保证第二次确实面对完整的同版本前端。 """ - (public / "node_modules" / "express").mkdir(parents=True, exist_ok=True) + (kwargs["cwd"] / "node_modules" / "express").mkdir(parents=True, exist_ok=True) monkeypatch.setattr(module, "download_file", download_repacked_release) monkeypatch.setattr(module, "run", install_runtime_deps) diff --git a/tests/test_local_setup_review_regressions.py b/tests/test_local_setup_review_regressions.py new file mode 100644 index 000000000..59368cc41 --- /dev/null +++ b/tests/test_local_setup_review_regressions.py @@ -0,0 +1,318 @@ +""" +本地安装、迁移和 shell 启动入口的隔离审查回归。 +""" + +import importlib.util +import os +import subprocess +import sys +import zipfile +from pathlib import Path +from unittest.mock import Mock + +import pytest +from sqlalchemy import create_engine, text + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_script(relative, monkeypatch): + """ + 加载待测脚本,并恢复临时模块登记。 + """ + spec = importlib.util.spec_from_file_location("setup_review_" + Path(relative).stem, ROOT / relative) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, spec.name, module) + spec.loader.exec_module(module) + return module + + +def _run_python(source, tmp_path): + """ + 用全新进程和独立 SQLite 配置检验正式 CLI 装配。 + """ + environment = {**os.environ, "CONFIG_DIR": str(tmp_path / "config"), "DEBUG": "false", + "DB_BACKUP_ENABLE": "false", "SUPERUSER": "review_admin", + "SUPERUSER_PASSWORD": "ReviewPassword123!", "API_TOKEN": "review-only-token-123456"} + return subprocess.run([sys.executable, "-c", source], cwd=ROOT, env=environment, + capture_output=True, text=True, timeout=45) + + +def test_migration_generator_loads_all_canonical_models(tmp_path): + """ + 生成器在全新进程加载完整模型;真实 Alembic 比较不把现有表识别为删除。 + """ + result = _run_python(''' +import runpy +from unittest.mock import patch +from sqlalchemy import create_engine +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from app.db.base import Base +from app.db.models.user import User +engine = create_engine("sqlite://") +User.__table__.create(engine) +def check_revision(config, version, autogenerate): + assert autogenerate is True + assert "site" in {name.lower() for name in Base.metadata.tables} + with engine.connect() as connection: + changes = compare_metadata(MigrationContext.configure(connection), Base.metadata) + assert not any(change[0] == "remove_table" for change in changes) +with patch("builtins.input", return_value="review-only"), patch("alembic.command.revision", check_revision): + runpy.run_path("database/gen.py", run_name="__main__") +engine.dispose() +''', tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("wizard", [False, True]) +def test_admin_initialization_uses_valid_transaction(tmp_path, wizard): + """ + 非向导完整初始化及向导更新已存在管理员均提交真实短事务。 + """ + result = _run_python(''' +import importlib.util +from sqlalchemy import select +from app.runtime.config import settings +from app.db.session import SessionFactory, async_session_scope +from app.db.models.user import User +from app.db.adapters.transaction import TransactionalWriteRunner +from app.db import uow +from app.application.security.token import get_password_hash, verify_password +spec = importlib.util.spec_from_file_location("setup", "scripts/local_setup.py") +setup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(setup) +''' + (''' +from app.startup.initializers.database import prepare_database +runner = TransactionalWriteRunner(sync_session=SessionFactory, async_session=async_session_scope) +uow.configure_transaction_runners(sync=runner.sync, async_=runner.async_) +prepare_database() +from app.db.oper.user import UserOper +if UserOper().get_by_name("review_admin") is None: + UserOper().add(name="review_admin", email="review@example.invalid", hashed_password=get_password_hash("OldPassword123!"), is_active=False, is_superuser=False, avatar="") +settings.SUPERUSER_PASSWORD = "ChangedPassword123!" +setup._apply_local_system_config_inner({}) +expected_password = settings.SUPERUSER_PASSWORD +''' if wizard else ''' +assert uow._sync_transaction_runner is None +setup._sync_superuser_account_inner() +expected_password = settings.SUPERUSER_PASSWORD +''') + ''' +with SessionFactory() as session: + user = session.execute(select(User).where(User.name == "review_admin")).scalar_one() + assert user.is_active and user.is_superuser + assert verify_password(expected_password, user.hashed_password) +''', tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_standalone_admin_cli_from_outside_checkout(tmp_path): + """ + 真实脚本入口不依赖调用目录或PYTHONPATH,且只初始化临时配置库。 + """ + config = tmp_path / "config" + environment = {**os.environ, "PYTHONPATH": "", "CONFIG_DIR": str(config), "DEBUG": "false", + "DB_BACKUP_ENABLE": "false", "SUPERUSER": "review_admin", + "SUPERUSER_PASSWORD": "ReviewPassword123!", "API_TOKEN": "review-only-token-123456"} + result = subprocess.run([sys.executable, str(ROOT / "scripts/local_setup.py"), "sync-superuser", + "--config-dir", str(config)], cwd=tmp_path, env=environment, + capture_output=True, text=True, timeout=45) + assert result.returncode == 0, result.stdout + result.stderr + assert (config / "user.db").is_file() + + +@pytest.mark.parametrize("password", ['quote"slash\\secret', " leading and trailing ", "single'quote"]) +def test_env_password_roundtrip_does_not_duplicate_escaping(tmp_path, monkeypatch, password): + """ + 向导留空保留旧密码时,读取序列化值必须还原原始字符。 + """ + module = _load_script("scripts/local_setup.py", monkeypatch) + monkeypatch.setattr(module, "ENV_FILE", tmp_path / "app.env") + monkeypatch.setattr(module, "ensure_local_dirs", lambda: None) + module.write_env_value("SUPERUSER_PASSWORD", password) + assert module.read_env_value("SUPERUSER_PASSWORD") == password + + +def test_frontend_dependency_failure_preserves_existing_installation(tmp_path, monkeypatch): + """ + npm 安装失败保留旧静态文件和原 node_modules,不发布不完整的新版本。 + """ + module = _load_script("scripts/local_setup.py", monkeypatch) + public = tmp_path / "public" + (public / "node_modules").mkdir(parents=True) + (public / "index.html").write_text("old page") + (public / "node_modules/old").write_text("old dependency") + archive = tmp_path / "dist.zip" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("dist/version.txt", "v9.9.9") + bundle.writestr("dist/index.html", "new page") + npm = tmp_path / "npm" + npm.touch() + monkeypatch.setattr(module, "PUBLIC_DIR", public) + monkeypatch.setattr(module, "install_node_runtime", lambda _version: tmp_path / "node") + monkeypatch.setattr(module, "get_npm_bin", lambda: npm) + + def fail_dependencies(*_args, **_kwargs): + """ + 替换 npm 进程边界,不执行安装或联网。 + """ + raise subprocess.CalledProcessError(1, "isolated npm") + + monkeypatch.setattr(module, "run", fail_dependencies) + with pytest.raises(subprocess.CalledProcessError): + module.install_frontend("v9.9.9", "20", archive=archive) + assert (public / "index.html").read_text() == "old page" + assert (public / "node_modules/old").read_text() == "old dependency" + + +def test_frontend_publication_failure_restores_previous_directory(tmp_path, monkeypatch): + """ + 候选已经准备完成但发布rename失败时,旧页面仍被恢复到原目录。 + """ + module = _load_script("scripts/local_setup.py", monkeypatch) + public, staged = tmp_path / "public", tmp_path / "staged" + public.mkdir() + staged.mkdir() + (public / "index.html").write_text("old") + (staged / "index.html").write_text("new") + monkeypatch.setattr(module, "PUBLIC_DIR", public) + original_rename = Path.rename + + def fail_publication(path, destination): + """ + 仅模拟候选目录的最终原子发布失败,旧备份恢复使用真实rename。 + """ + if path == staged: + raise OSError("fixture publication failure") + return original_rename(path, destination) + + monkeypatch.setattr(Path, "rename", fail_publication) + with pytest.raises(OSError, match="fixture publication"): + module._publish_frontend_installation(staged) + assert (public / "index.html").read_text() == "old" + assert not list(tmp_path.glob(".public.backup-*")) + + +@pytest.mark.parametrize("failure", [False, True]) +def test_local_database_transaction_scope_restores_existing_runners(monkeypatch, failure): + """ + 命令不遗留全局事务执行器,操作成功与异常都恢复进入时的原端口。 + """ + from app.db import uow + module = _load_script("scripts/local_setup.py", monkeypatch) + previous = (uow._sync_transaction_runner, uow._async_transaction_runner) + + def operation(): + """ + 观察执行范围内真实runner已装配,并按参数产生受控异常。 + """ + assert uow._sync_transaction_runner is not None + assert uow._sync_transaction_runner is not previous[0] + if failure: + raise RuntimeError("fixture operation failure") + + if failure: + with pytest.raises(RuntimeError, match="fixture operation"): + module._run_with_local_database_transactions(operation) + else: + module._run_with_local_database_transactions(operation) + assert (uow._sync_transaction_runner, uow._async_transaction_runner) == previous + + +def test_duplicate_config_migration_never_logs_secret_values(tmp_path, monkeypatch): + """ + 真实迁移仍删除旧重复项,日志仅含键与行号。 + """ + module = _load_script("database/versions/41ef1dd7467c_2_2_2.py", monkeypatch) + engine = create_engine("sqlite:///" + str(tmp_path / "migration.db")) + logger = Mock() + monkeypatch.setattr(module, "logger", logger) + try: + with engine.begin() as connection: + connection.execute(text("CREATE TABLE SystemConfig (id INTEGER PRIMARY KEY, key TEXT, value TEXT)")) + connection.execute(text("INSERT INTO SystemConfig VALUES (1, 'Password', 'fixture-secret'), (2, 'Password', 'new-secret')")) + monkeypatch.setattr(module.op, "get_bind", lambda: connection) + module.upgrade() + assert connection.execute(text("SELECT id FROM SystemConfig")).scalars().all() == [2] + assert "fixture-secret" not in str(logger.mock_calls) + assert "Password" in str(logger.mock_calls) + finally: + engine.dispose() + + +def test_bootstrap_explicit_link_path_survives_os_detection(tmp_path): + """ + 正式参数解析和系统检测保留用户显式指定的命令路径。 + """ + prefix = (ROOT / "scripts/bootstrap-local.sh").read_text().split("\ndetect_os\n", 1)[0] + path = tmp_path / "custom-bin/moviepilot" + result = subprocess.run(["bash", "-c", prefix + '\ndetect_os\nprintf "%s\\n" "$LINK_PATH"', + "review", "--link-path", str(path)], capture_output=True, text=True, timeout=5) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(path) + + +def test_noninteractive_bootstrap_with_tty_skips_wizard(): + """ + 有终端的自动安装仍遵守 --non-interactive。 + """ + source = (ROOT / "scripts/bootstrap-local.sh").read_text() + prefix, main = source.split("\ndetect_os\n", 1) + decision = main[main.index('if [[ "$NON_INTERACTIVE" != "true"'):main.index('\nmkdir -p "$WORKDIR"')] + script = prefix + '\nHAS_TTY=true\n' + decision + '\nprintf "%s\\n" "$RUN_WIZARD"' + result = subprocess.run(["bash", "-c", script, "review", "--non-interactive"], + capture_output=True, text=True, timeout=5) + assert result.returncode == 0, result.stderr + assert result.stdout.strip().splitlines()[-1] == "false" + + +@pytest.mark.parametrize("has_environment", [False, True]) +def test_start_local_help_and_documented_dotvenv(tmp_path, has_environment): + """ + 无环境可查看帮助;uv sync 创建的 .venv 可执行实际启动分支。 + """ + (tmp_path / "scripts").mkdir() + start = tmp_path / "scripts/start-local.sh" + start.write_bytes((ROOT / "scripts/start-local.sh").read_bytes()) + cli = tmp_path / "moviepilot" + cli.write_text("#!/bin/bash\nexit 99\n") + cli.chmod(0o755) + if has_environment: + python = tmp_path / ".venv/bin/python" + python.parent.mkdir(parents=True) + python.write_text('#!/bin/bash\nprintf "fixture python %s\\n" "$*"\n') + python.chmod(0o755) + result = subprocess.run(["bash", str(start), "backend" if has_environment else "help"], + capture_output=True, text=True, timeout=5) + assert result.returncode == 0, result.stderr + assert ("fixture python -m app.main" if has_environment else "用法") in result.stdout + + +@pytest.mark.parametrize("initial", ["0", "1"]) +def test_pending_release_install_is_reachable_before_workers(tmp_path, initial): + """ + 不论自动更新是否已经完成,持久化 Release 安装清单都进入启动前恢复。 + """ + config = tmp_path / "config" + manifest = config / "temp/moviepilot-update/install.json" + manifest.parent.mkdir(parents=True) + manifest.write_text("{}") + control = tmp_path / "control" + control.mkdir() + (control / "update.sh").write_text("recover_pending_update() { return 0; }\nrun_moviepilot_update() { :; }\n") + source = (ROOT / "docker/entrypoint.sh").read_text() + begin = source.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"') + end = source.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"', begin) + script = ''' +INFO() { :; } +ERROR() { echo "$*" >&2; } +maybe_reexec_control_bundle() { :; } +cd() { :; } +apply_pending_release_update_at_startup() { echo APPLIED; return 0; } +reexec_entrypoint() { echo REEXEC; } +''' + source[begin:end] + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=5, + env={**os.environ, "CONFIG_DIR": str(config), "MP_CONTROL_DIR": str(control), + "MOVIEPILOT_BOOTSTRAP_UPDATE_DONE": initial, "ONE_SHOT_DEV_UPDATE": "false"}) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["APPLIED", "REEXEC"] diff --git a/tests/test_service_locator_gate.py b/tests/test_service_locator_gate.py index 86c64da3f..ac98f1d3f 100644 --- a/tests/test_service_locator_gate.py +++ b/tests/test_service_locator_gate.py @@ -118,3 +118,24 @@ assert gateway._provider_runtime_factory is None ) assert result.returncode == 0, result.stderr or result.stdout + + +def test_cli_agent_is_an_exact_runtime_owner_without_subtree_exemption() -> None: + """ + 只允许独占 CLI 组合根持有模块和插件 owner,相邻消费者及子模块仍须走门面。 + """ + dependencies = {"app.runtime.extensions.module.manager", "app.runtime.extensions.plugin.manager"} + graph = { + "app.startup.composition.cliagent": dependencies, + "app.startup.composition.cliagent.consumer": dependencies, + "app.startup.composition.cliagent_tools": dependencies, + } + + violations = collect_service_locator_violations(graph) + + assert [(item.source, item.policy) for item in violations] == [ + ("app.startup.composition.cliagent.consumer", "module"), + ("app.startup.composition.cliagent_tools", "module"), + ("app.startup.composition.cliagent.consumer", "plugin"), + ("app.startup.composition.cliagent_tools", "plugin"), + ] diff --git a/tests/test_user_oper_update.py b/tests/test_user_oper_update.py new file mode 100644 index 000000000..e187d5726 --- /dev/null +++ b/tests/test_user_oper_update.py @@ -0,0 +1,174 @@ +""" +使用临时 SQLite 验证用户同步更新的短事务、回滚和执行器作用域。 +""" + +import asyncio +from contextlib import asynccontextmanager + +import pytest +from sqlalchemy import create_engine, inspect +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.db.adapters.transaction import TransactionalWriteRunner +from app.db.models.user import User +from app.db.oper.user import UserOper +from app.db.uow import transaction_runner_scope + + +@pytest.fixture +def user_database_factory(tmp_path): + """ + 按名称创建互不共享数据的临时用户表及真实事务执行器。 + """ + engines = [] + + def create_database(name): + """ + 为一个作用域创建数据库,异步入口按调用释放自己的引擎。 + """ + path = tmp_path / f"{name}.db" + engine = create_engine(f"sqlite:///{path}") + engines.append(engine) + User.__table__.create(engine) + factory = sessionmaker(bind=engine) + + @asynccontextmanager + async def async_session(): + """ + 为同一数据库提供可独立关闭的真实异步会话。 + """ + async_engine = create_async_engine(f"sqlite+aiosqlite:///{path}") + try: + async with async_sessionmaker(bind=async_engine)() as session: + yield session + finally: + await async_engine.dispose() + + return factory, TransactionalWriteRunner( + sync_session=factory, + async_session=async_session, + ) + + try: + yield create_database + finally: + for engine in engines: + engine.dispose() + + +def _insert_user(factory, name="admin"): + """ + 提交一个启用的管理员,返回关闭会话后仍有效的主键。 + """ + with factory() as session: + user = User( + name=name, + email="before@example.invalid", + hashed_password="old-hash", + is_active=True, + is_superuser=True, + ) + session.add(user) + session.flush() + user_id = user.id + session.commit() + return user_id + + +def test_update_commits_fields_after_previous_query_session_closes(user_database_factory): + """ + 更新重新查询已游离的管理员,提交全部字段并返回可读取的对象。 + """ + factory, runner = user_database_factory("persist") + user_id = _insert_user(factory) + payload = { + "email": "after@example.invalid", + "hashed_password": "new-hash", + "is_active": False, + "is_superuser": False, + "permissions": {"search": True}, + "settings": {"language": "zh"}, + } + + with transaction_runner_scope(sync=runner.sync, async_=runner.async_): + oper = UserOper() + previous = oper.get_by_id(user_id) + assert previous is not None + assert inspect(previous).detached + + updated = oper.update(user_id, payload) + + assert updated is not None + assert updated is not previous + assert inspect(updated).detached + assert updated.id == user_id + assert previous.email == "before@example.invalid" + for field, value in payload.items(): + assert getattr(updated, field) == value + with factory() as session: + persisted = session.get(User, user_id) + assert persisted is not None + for field, value in payload.items(): + assert getattr(persisted, field) == value + + +def test_update_returns_none_when_previously_queried_user_was_deleted(user_database_factory): + """ + 查询后已删除的用户不会被更新入口重新插入。 + """ + factory, runner = user_database_factory("missing") + user_id = _insert_user(factory) + + with transaction_runner_scope(sync=runner.sync, async_=runner.async_): + oper = UserOper() + assert oper.get_by_id(user_id) is not None + with factory() as session: + session.delete(session.get(User, user_id)) + session.commit() + + assert oper.update(user_id, {"hashed_password": "new-hash"}) is None + assert oper.list() == [] + + with factory() as session: + assert session.get(User, user_id) is None + + +def test_update_failure_rolls_back_and_restores_previous_runners(user_database_factory): + """ + 唯一约束失败回滚全部字段,退出嵌套作用域后恢复同步和异步执行器。 + """ + outer_factory, outer_runner = user_database_factory("outer") + inner_factory, inner_runner = user_database_factory("inner") + outer_id = _insert_user(outer_factory, "outer-admin") + inner_id = _insert_user(inner_factory, "inner-admin") + _insert_user(inner_factory, "occupied-name") + + with transaction_runner_scope(sync=outer_runner.sync, async_=outer_runner.async_): + with pytest.raises(IntegrityError): + with transaction_runner_scope(sync=inner_runner.sync, async_=inner_runner.async_): + UserOper().update(inner_id, { + "name": "occupied-name", + "email": "must-rollback@example.invalid", + "hashed_password": "must-rollback", + }) + + with inner_factory() as session: + persisted = session.get(User, inner_id) + assert persisted is not None + assert persisted.name == "inner-admin" + assert persisted.email == "before@example.invalid" + assert persisted.hashed_password == "old-hash" + + restored = UserOper().update(outer_id, {"email": "restored@example.invalid"}) + assert restored is not None + assert restored.name == "outer-admin" + async_user = asyncio.run(UserOper().async_get_by_name("outer-admin")) + assert async_user is not None + assert async_user.email == "restored@example.invalid" + + with outer_factory() as session: + assert session.get(User, outer_id).email == "restored@example.invalid" + with inner_factory() as session: + assert session.get(User, inner_id).email == "before@example.invalid"