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
This commit is contained in:
Aqr-K
2026-10-23 06:49:13 +08:00
committed by GitHub
parent 5fc4f2efd8
commit d51320103c
44 changed files with 1720 additions and 131 deletions

View File

@@ -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]:
"""
异步获取用户列表。

View File

@@ -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 操作。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`
## 服务管理命令

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}'''

View File

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

View File

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

View File

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

View File

@@ -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"(?<![A-Za-z])(?:[0-9a-f]{8,}|\d+(?:\.\d+)?)(?![A-Za-z])",

View File

@@ -1,6 +1,6 @@
---
name: publish-moviepilot-plugin
version: 3
version: 4
description: >-
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 的重复插件发现也应在反馈摘要中合并展示。

View File

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

View File

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

View File

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

View File

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