From f3fd6ee52480d8991c139ebe0e02f783ea9c8a66 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Mon, 17 Aug 2026 17:36:12 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(plugin):=20=E5=AE=8C=E6=88=90=20Fitbit?= =?UTF-8?q?=20v3=20=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plugin-api-v2.yml | 28 ---- .github/workflows/plugin-api-v3.yml | 81 ++++++++++ akashic.plugin.toml | 46 ++++++ dashboard.py | 12 +- docs/v3-data-migration.md | 15 ++ monitor/server.py | 4 +- plugin.py | 216 +++++++++++++------------ pyrightconfig.json | 13 ++ scripts/__init__.py | 1 + scripts/migrate_v2_data.py | 237 ++++++++++++++++++++++++++++ src/mcp_bridge.py | 79 ++++++++-- tests/test_dashboard.py | 25 ++- tests/test_manager_integration.py | 157 ++++++++++++++++++ tests/test_mcp_v3_runtime.py | 79 ++++++++++ tests/test_migrate_v2_data.py | 128 +++++++++++++++ tests/test_plugin.py | 190 +++++++++++++++++----- 16 files changed, 1110 insertions(+), 201 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml create mode 100644 .github/workflows/plugin-api-v3.yml create mode 100644 akashic.plugin.toml create mode 100644 docs/v3-data-migration.md create mode 100644 pyrightconfig.json create mode 100644 scripts/__init__.py create mode 100644 scripts/migrate_v2_data.py create mode 100644 tests/test_manager_integration.py create mode 100644 tests/test_mcp_v3_runtime.py create mode 100644 tests/test_migrate_v2_data.py diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml deleted file mode 100644 index 7c1876b..0000000 --- a/.github/workflows/plugin-api-v2.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: plugin-api-v2 - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: akashic-plugins/plugin-contracts - ref: 24543445c7b99ca63fcd90b5828f754a148b184c - path: .plugin-contracts - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Check Plugin API v2 - env: - PYTHONPATH: .plugin-contracts - run: python -m akashic_plugin_contracts check plugin.py diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml new file mode 100644 index 0000000..05cf36d --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,81 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/plugin-contracts + ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf + path: .plugin-contracts + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check Plugin API v3 + env: + PYTHONPATH: .plugin-contracts + run: python -m akashic_plugin_contracts check plugin.py + + plugin-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: 78e50d4dfb3f4348fff37d55d9c9bdd0e002164d + path: .akashic-core + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: | + .akashic-core/requirements.txt + requirements.txt + - name: Stage exact Fitbit runtime + run: | + python -m venv .venv + .venv/bin/python -m pip install \ + -r .akashic-core/requirements.txt \ + -r .akashic-core/requirements-dev.txt \ + -r requirements.txt + - name: Verify Fitbit v3 composition + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: .venv/bin/python -m pytest -q tests/ + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Verify Fitbit panels + run: | + npm ci + npm test + - name: Check v3 source types + env: + PYTHONPATH: .akashic-core + run: >- + .venv/bin/basedpyright --level error + plugin.py dashboard.py src/mcp_bridge.py + scripts/migrate_v2_data.py + tests/test_plugin.py tests/test_dashboard.py + tests/test_manager_integration.py tests/test_mcp_v3_runtime.py + tests/test_migrate_v2_data.py + - name: Compile Python sources + run: python -m compileall -q plugin.py dashboard.py src monitor scripts tests + - name: Check diff formatting + run: git diff --check diff --git a/akashic.plugin.toml b/akashic.plugin.toml new file mode 100644 index 0000000..3887d38 --- /dev/null +++ b/akashic.plugin.toml @@ -0,0 +1,46 @@ +schema_version = 1 +name = "fitbit" +version = "3.0.0" +api_version = 3 +entrypoint = "plugin.py" + +[[python]] +requirements = "requirements.txt" + +[validation] +exclude_data_paths = [ + "monitor.config.toml", + "monitor.config.local.toml", + "tokens.json", + "sleep_log.jsonl", + "sleep_labels.json", + "sleep_model.pkl", + "stat_events.json", + "stat_events_v2.json", + "mobile_sleep_projection.json", + "monitor.runtime.log", + "backups", + "logs", + ".fitbit-v2-migration.json", +] + +[[processes]] +name = "monitor" +command = ["python", "monitor/server.py"] +cwd = "." +port_env = "FITBIT_MONITOR_PORT" +formal_port = 18765 +readiness_path = "/api/data" +startup_timeout_seconds = 15.0 + +[[mcp]] +name = "fitbit" +command = ["python", "run_mcp.py"] +required_tools = [ + "get_proactive_events", + "get_sleep_context", + "acknowledge_events", +] +candidate_read_only_tools = ["get_proactive_events", "get_sleep_context"] +endpoint_env = [{env = "FITBIT_MONITOR_PORT", process = "monitor"}] +candidate_env = {FITBIT_BACKEND = "recording"} diff --git a/dashboard.py b/dashboard.py index 051f77c..aa85837 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,20 +1,22 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any import requests from fastapi import FastAPI, HTTPException from fastapi.responses import RedirectResponse +from agent.plugin_composition import DashboardContext + _MONITOR_URL = "http://127.0.0.1:18765" -def register(app: FastAPI, plugin_dir: object, workspace: object) -> None: +def register(app: FastAPI, context: DashboardContext) -> None: """Expose the monitor's current snapshot through the Akashic Dashboard.""" - _ = plugin_dir, workspace + _ = context @app.get("/api/dashboard/fitbit/overview") def overview() -> dict[str, object]: @@ -71,7 +73,7 @@ def _project_dashboard_snapshot(payload: Mapping[str, object]) -> dict[str, obje def _project_overview( data: Mapping[str, object], snapshot: Mapping[str, object], - history: list[object] | None = None, + history: Sequence[object] | None = None, ) -> dict[str, object]: """Validate monitor payloads and build the Dashboard first-screen DTO.""" @@ -113,7 +115,7 @@ def _project_overview( } -def _prediction_events(rows: list[object]) -> list[dict[str, object]]: +def _prediction_events(rows: Sequence[object]) -> list[dict[str, object]]: """Project stored model outputs separately from final sleep decisions.""" events: list[dict[str, object]] = [] diff --git a/docs/v3-data-migration.md b/docs/v3-data-migration.md new file mode 100644 index 0000000..c32df29 --- /dev/null +++ b/docs/v3-data-migration.md @@ -0,0 +1,15 @@ +# Fitbit v3 数据迁移 + +Fitbit v3 只读写 `/plugin-data/fitbit-`。旧数据不会在插件加载时自动移动,也不会被删除。 + +停用旧 Fitbit runtime 后,显式执行: + +```bash +python scripts/migrate_v2_data.py \ + --workspace \ + --marketplace github +``` + +迁移持有 workspace 实例锁,从 `mcp/fitbit-mcp/monitor` 复制已知数据文件,并在目标目录写入 `.fitbit-v2-migration.json` 内容回执。旧目录始终保留,作为恢复点。 + +进程内失败会删除本次新发布的目标文件;Core 进程崩溃后再次执行同一命令,会校验已发布内容并继续完成。目标已有不同内容或回执与文件不一致时,命令会明确失败,不覆盖数据。 diff --git a/monitor/server.py b/monitor/server.py index 01bfe23..e88edf6 100644 --- a/monitor/server.py +++ b/monitor/server.py @@ -466,7 +466,9 @@ def _load_runtime_config() -> dict: _get_cfg(CONFIG, ("server", "host"), DEFAULT_CONFIG["server"]["host"]) ) SERVER_PORT = _as_int( - _get_cfg(CONFIG, ("server", "port"), DEFAULT_CONFIG["server"]["port"]), 18765 + os.environ.get("FITBIT_MONITOR_PORT") + or _get_cfg(CONFIG, ("server", "port"), DEFAULT_CONFIG["server"]["port"]), + 18765, ) SERVER_LOG_LEVEL = str( _get_cfg(CONFIG, ("server", "log_level"), DEFAULT_CONFIG["server"]["log_level"]) diff --git a/plugin.py b/plugin.py index 26bc7fd..3427df3 100644 --- a/plugin.py +++ b/plugin.py @@ -1,22 +1,25 @@ from __future__ import annotations -import shutil from collections.abc import Mapping from pathlib import Path -from typing import cast import requests from pydantic import BaseModel, Field -from agent.plugins import ( - ManagedServiceSpec, - McpServerSpec, - MobileUiContribution, +from agent.plugin_composition import ( + MANAGED_PROCESSES, + MCP_SERVERS, + PROACTIVE_COMPONENTS, + UI_SLOTS, + Context, + EndpointEnv, + ManagedProcessDefinition, + McpServerDefinition, + MobileUiDefinition, MobileUiNavigation, - Plugin, - ProactiveSourceSpec, + MobileUiRpcInvalidRequest, + ProactiveSourceDefinition, ) -from agent.plugins.mobile_ui import MobileUiRpcInvalidRequest _MONITOR_URL = "http://127.0.0.1:18765" @@ -196,113 +199,106 @@ class FitbitConfig(BaseModel): proactive: FitbitProactiveConfig = Field(default_factory=FitbitProactiveConfig) -class FitbitPlugin(Plugin): - api_version = 2 - name = "fitbit" - version = "1.4.0" - desc = "Fitbit health monitor and sleep model" - ConfigModel = FitbitConfig - - @classmethod - def mobile_ui(cls) -> MobileUiContribution: - return MobileUiContribution( - module="mobile_panel.js", - stylesheet="mobile_panel.css", - navigation=MobileUiNavigation( - label="健康状态", - description="查看当前心率、血氧、步数和最近睡眠节律", +api_version = 3 +name = "fitbit" +version = "3.0.0" +desc = "Fitbit health monitor and sleep model" +Config = FitbitConfig +inject = (MANAGED_PROCESSES, MCP_SERVERS, PROACTIVE_COMPONENTS, UI_SLOTS) +dashboard_module = "dashboard.py" + + +async def apply(ctx: Context, config: FitbitConfig) -> None: + """登记 Fitbit 进程、MCP、主动源和移动端只读投影。""" + + # 1. Core 独占 monitor 端口、进程健康和 MCP endpoint 投影。 + await ctx.require(MANAGED_PROCESSES).register( + ctx, + ManagedProcessDefinition( + name="monitor", + command=("python", "monitor/server.py"), + cwd=".", + port_env="FITBIT_MONITOR_PORT", + formal_port=18765, + readiness_path="/api/data", + startup_timeout_seconds=15.0, + ), + ) + await ctx.require(MCP_SERVERS).register( + ctx, + McpServerDefinition( + name="fitbit", + command=("python", "run_mcp.py"), + required_tools=( + "get_proactive_events", + "get_sleep_context", + "acknowledge_events", ), - ) - - @classmethod - def dashboard_module(cls) -> str: - return "dashboard.py" - - @classmethod - def mcp_servers(cls) -> list[McpServerSpec]: - return [McpServerSpec(name="fitbit", command=("python", "run_mcp.py"))] - - @classmethod - def managed_services(cls) -> list[ManagedServiceSpec]: - return [ - ManagedServiceSpec( - id="monitor", - command=("python", "monitor/server.py"), - cwd="monitor", - readiness_url="http://127.0.0.1:18765/api/data", - startup_timeout_seconds=15, - ) - ] - - def proactive_sources(self) -> list[ProactiveSourceSpec]: - config = cast(FitbitConfig, self.context.config) - if not config.proactive.enabled: - return [] - return [ - ProactiveSourceSpec( - id="health_alerts", + candidate_read_only_tools=( + "get_proactive_events", + "get_sleep_context", + ), + endpoint_env=(EndpointEnv("FITBIT_MONITOR_PORT", "monitor"),), + candidate_env={"FITBIT_BACKEND": "recording"}, + ), + ) + + # 2. 主动源只消费 typed fetch/ack,不直接持有 monitor 或进程。 + if config.proactive.enabled: + proactive = ctx.require(PROACTIVE_COMPONENTS) + await proactive.register( + ctx, + ProactiveSourceDefinition( + name="health_alerts", channels=("alert",), - server="fitbit", + mcp_server="fitbit", fetch_tool="get_proactive_events", ack_tool="acknowledge_events", ), - ProactiveSourceSpec( - id="sleep_context", + ) + await proactive.register( + ctx, + ProactiveSourceDefinition( + name="sleep_context", channels=("context",), - server="fitbit", + mcp_server="fitbit", fetch_tool="get_sleep_context", ), - ] + ) + + # 3. 静态资产与同步只读查询绑定当前 exact Root。 + await ctx.require(UI_SLOTS).register_mobile( + ctx, + MobileUiDefinition( + module="mobile_panel.js", + stylesheet="mobile_panel.css", + navigation=MobileUiNavigation( + label="健康状态", + description="查看当前心率、血氧、步数和最近睡眠节律", + ), + ), + query=_mobile_ui_query, + ) + + +def _mobile_ui_query( + method: str, + payload: dict[str, object], + *, + session_id: str | None, + turn_id: str | None, +) -> dict[str, object]: + """按数据源独立返回当前健康或睡眠历史投影。""" + + # 1. 插件边界只暴露两种只读投影。 + _ = payload, session_id, turn_id + readers = { + "fitbit.current": FitbitMobileDashboardReader.get_current, + "fitbit.sleep_history": FitbitMobileDashboardReader.get_sleep_history, + } + reader_method = readers.get(method) + if reader_method is None: + raise MobileUiRpcInvalidRequest(f"未知 fitbit 移动方法: {method}") - def mobile_ui_query( - self, - method: str, - payload: dict[str, object], - *, - session_id: str | None, - turn_id: str | None, - ) -> dict[str, object]: - """按数据源独立返回当前健康或睡眠历史投影。""" - - # 1. 插件边界只暴露两种只读投影 - _ = payload, session_id, turn_id - readers = { - "fitbit.current": FitbitMobileDashboardReader.get_current, - "fitbit.sleep_history": FitbitMobileDashboardReader.get_sleep_history, - } - reader_method = readers.get(method) - if reader_method is None: - raise MobileUiRpcInvalidRequest(f"未知 fitbit 移动方法: {method}") - - # 2. 调度器已把同步查询隔离到专用线程池 - reader = FitbitMobileDashboardReader() - return reader_method(reader) - - def activate(self) -> None: - data_dir = self.context.data_dir - if data_dir is None: - return - data_dir.mkdir(parents=True, exist_ok=True) - self._migrate_legacy_state(data_dir) - - def _migrate_legacy_state(self, data_dir: Path) -> None: - workspace = self.context.workspace - if workspace is None: - return - legacy = workspace / "mcp" / "fitbit-mcp" / "monitor" - if not legacy.is_dir(): - return - for name in ( - "monitor.config.toml", - "monitor.config.local.toml", - "tokens.json", - "sleep_log.jsonl", - "sleep_labels.json", - "sleep_model.pkl", - "stat_events.json", - "stat_events_v2.json", - ): - source = legacy / name - target = data_dir / name - if source.exists() and not target.exists(): - shutil.copy2(source, target) + # 2. Core 调度器会把同步查询隔离到专用线程池。 + return reader_method(FitbitMobileDashboardReader()) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..434a255 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,13 @@ +{ + "include": ["plugin.py", "dashboard.py", "src", "monitor", "scripts", "tests"], + "exclude": ["**/__pycache__"], + "venvPath": ".", + "venv": ".venv", + "executionEnvironments": [ + { + "root": ".", + "pythonVersion": "3.13", + "extraPaths": [".akashic-core"] + } + ] +} diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e281bc4 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Fitbit 运维脚本。""" diff --git a/scripts/migrate_v2_data.py b/scripts/migrate_v2_data.py new file mode 100644 index 0000000..de73148 --- /dev/null +++ b/scripts/migrate_v2_data.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""把 Fitbit v2 workspace 数据非破坏迁移到 v3 plugin-data。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import uuid +from pathlib import Path + +from agent.plugins.manifest import ( + ensure_workspace_plugin_data_dir, + validate_workspace_plugin_data_path, +) +from bootstrap.workspace_lock import WorkspaceInstanceLock + + +_DATA_FILES = ( + "monitor.config.toml", + "monitor.config.local.toml", + "tokens.json", + "sleep_log.jsonl", + "sleep_labels.json", + "sleep_model.pkl", + "stat_events.json", + "stat_events_v2.json", +) +_RECEIPT = ".fitbit-v2-migration.json" + + +def _digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def _remove_crash_staging(workspace: Path) -> None: + """清理上次 Core 进程崩溃留下的未发布 staging。""" + + parent = workspace / "plugin-data" + if parent.is_symlink(): + raise ValueError(f"Fitbit plugin-data 根不得是符号链接: {parent}") + if not parent.is_dir(): + return + for path in parent.glob(".fitbit-v2-migrate-*"): + if path.is_symlink() or not path.is_dir(): + raise ValueError(f"Fitbit migration staging 无效: {path}") + shutil.rmtree(path) + + +def _read_receipt(path: Path) -> dict[str, object] | None: + if not path.exists() and not path.is_symlink(): + return None + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Fitbit migration receipt 不是普通文件: {path}") + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise ValueError(f"Fitbit migration receipt 无效: {path}") + return raw + + +def _verify_published( + target: Path, + receipt: dict[str, object], +) -> dict[str, object]: + """验证既有 receipt 与全部已发布目标仍完全一致。""" + + files = receipt.get("files") + if ( + receipt.get("source") != "mcp/fitbit-mcp/monitor" + or receipt.get("target") != f"plugin-data/{target.name}" + or receipt.get("source_retained") is not True + or not isinstance(files, list) + or not files + ): + raise ValueError("Fitbit migration receipt 身份无效") + seen: set[str] = set() + for raw in files: + if not isinstance(raw, dict): + raise ValueError("Fitbit migration receipt file 条目无效") + name = raw.get("name") + expected = raw.get("sha256") + size = raw.get("size") + if ( + not isinstance(name, str) + or name not in _DATA_FILES + or name in seen + or not isinstance(expected, str) + or len(expected) != 64 + or not isinstance(size, int) + or isinstance(size, bool) + or size < 0 + ): + raise ValueError("Fitbit migration receipt file 条目无效") + seen.add(name) + path = target / name + if ( + path.is_symlink() + or not path.is_file() + or path.stat().st_size != size + or _digest(path) != expected + ): + raise ValueError(f"Fitbit migration 目标内容漂移: {path}") + return receipt + + +def _stage(source: Path, staging: Path) -> list[dict[str, object]]: + """复制 v2 权威文件到隔离 staging,并冻结内容证据。""" + + files: list[dict[str, object]] = [] + for name in _DATA_FILES: + source_file = source / name + if not source_file.exists() and not source_file.is_symlink(): + continue + if source_file.is_symlink() or not source_file.is_file(): + raise ValueError(f"Fitbit v2 数据不是普通文件: {source_file}") + staged = staging / name + shutil.copy2(source_file, staged) + files.append( + { + "name": name, + "sha256": _digest(staged), + "size": staged.stat().st_size, + } + ) + if not files: + raise FileNotFoundError("Fitbit v2 数据目录没有可迁移文件") + return files + + +def _publish( + staging: Path, + target: Path, + files: list[dict[str, object]], + receipt: dict[str, object], +) -> None: + """发布本次新增文件,进程内失败时完整回滚。""" + + published: list[Path] = [] + receipt_path = target / _RECEIPT + try: + for item in files: + name = str(item["name"]) + destination = target / name + if destination.is_symlink(): + raise ValueError(f"Fitbit v3 目标不得是符号链接: {destination}") + if destination.exists(): + if not destination.is_file() or _digest(destination) != item["sha256"]: + raise FileExistsError(f"Fitbit v3 目标已存在且内容不同: {destination}") + continue + os.replace(staging / name, destination) + published.append(destination) + staged_receipt = staging / _RECEIPT + staged_receipt.write_text( + json.dumps(receipt, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.replace(staged_receipt, receipt_path) + except BaseException: + for path in reversed(published): + path.unlink(missing_ok=True) + raise + + +def _migrate_locked(workspace: Path, marketplace: str) -> dict[str, object]: + """在 workspace 独占区间完成一次可重入迁移。""" + + if not marketplace or not marketplace.replace("-", "").replace("_", "").isalnum(): + raise ValueError(f"Fitbit marketplace 无效: {marketplace}") + _remove_crash_staging(workspace) + source = workspace / "mcp" / "fitbit-mcp" / "monitor" + if source.is_symlink() or not source.is_dir() or not source.is_relative_to(workspace): + raise ValueError(f"Fitbit v2 数据目录不存在或不安全: {source}") + target = workspace / "plugin-data" / f"fitbit-{marketplace}" + validate_workspace_plugin_data_path(target, workspace) + existing = _read_receipt(target / _RECEIPT) + if existing is not None: + return _verify_published(target, existing) + + parent = workspace / "plugin-data" + parent.mkdir(parents=True, exist_ok=True) + staging = parent / f".fitbit-v2-migrate-{uuid.uuid4().hex}" + staging.mkdir() + target_created = not target.exists() + try: + files = _stage(source, staging) + ensure_workspace_plugin_data_dir(target, workspace) + receipt: dict[str, object] = { + "schema_version": 1, + "source": "mcp/fitbit-mcp/monitor", + "target": f"plugin-data/fitbit-{marketplace}", + "source_retained": True, + "files": files, + } + _publish(staging, target, files, receipt) + return receipt + except BaseException: + if target_created and target.is_dir() and not any(target.iterdir()): + target.rmdir() + raise + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def migrate_v2_data(workspace: Path, marketplace: str) -> dict[str, object]: + """持有 workspace 独占锁迁移 Fitbit 数据并返回 receipt。""" + + resolved = workspace.expanduser().resolve() + lock = WorkspaceInstanceLock(resolved) + lock.acquire() + try: + return _migrate_locked(resolved, marketplace) + finally: + lock.release() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--marketplace", default="github") + args = parser.parse_args() + print( + json.dumps( + migrate_v2_data(args.workspace, args.marketplace), + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/mcp_bridge.py b/src/mcp_bridge.py index f8e75c9..068b07e 100644 --- a/src/mcp_bridge.py +++ b/src/mcp_bridge.py @@ -20,6 +20,7 @@ import logging import os from datetime import datetime, timedelta, timezone +from typing import Any import requests from mcp.server.fastmcp import FastMCP @@ -32,6 +33,10 @@ _last_wake_presence = "unknown" +def _recording_backend() -> bool: + return os.environ.get("FITBIT_BACKEND", "").strip().lower() == "recording" + + def _monitor_available(timeout: float = 1.0) -> bool: try: resp = requests.get(f"{BASE_URL}/api/data", timeout=timeout) @@ -41,7 +46,7 @@ def _monitor_available(timeout: float = 1.0) -> bool: return False -def _to_standard_event(raw: dict) -> dict: +def _to_standard_event(raw: dict[str, Any]) -> dict[str, Any]: """把 fitbit-monitor 的原始事件 dict 转换为标准 ProactiveEvent schema。""" created_at = raw.get("created_at") published_at = None @@ -65,14 +70,14 @@ def _to_standard_event(raw: dict) -> dict: } -def _fetch_agent_payload(timeout: int = 5) -> dict: +def _fetch_agent_payload(timeout: int = 5) -> dict[str, Any]: resp = requests.get(f"{BASE_URL}/api/agent", timeout=timeout) resp.raise_for_status() data = resp.json() return data if isinstance(data, dict) else {} -def _build_sleep_context(data: dict) -> dict: +def _build_sleep_context(data: dict[str, Any]) -> dict[str, Any]: sleep = data.get("sleep", {}) or {} state = str(sleep.get("state", "unknown") or "unknown") prob = sleep.get("prob") @@ -117,12 +122,12 @@ def _build_sleep_context(data: dict) -> dict: def _with_wake_contract( - payload: dict, + payload: dict[str, Any], *, state: str, probability: object, observed_at: datetime | None = None, -) -> dict: +) -> dict[str, Any]: global _last_wake_presence presence = { "sleeping": "sleeping", @@ -167,7 +172,7 @@ def _bounded_probability(value: object) -> float: return 0.5 -def _unavailable_sleep_context(hint: str) -> dict: +def _unavailable_sleep_context(hint: str) -> dict[str, Any]: payload = { "available": False, "topic": "", @@ -193,36 +198,64 @@ def get_proactive_events() -> str: 返回标准 ProactiveEvent alert schema 的 JSON 数组。 空数组表示当前无待处理告警。 """ + if _recording_backend(): + return json.dumps({"status": "empty"}, ensure_ascii=False) try: data = _fetch_agent_payload(timeout=5) raw_events = data.get("health_events") or [] events = [_to_standard_event(e) for e in raw_events] - return json.dumps(events, ensure_ascii=False) - except requests.exceptions.ConnectionError: + payload = ( + {"status": "items", "items": events} + if events + else {"status": "empty"} + ) + return json.dumps(payload, ensure_ascii=False) + except requests.exceptions.ConnectionError as error: logger.warning("fitbit-monitor 未运行 (%s)", BASE_URL) - return json.dumps([]) + return json.dumps( + {"status": "failure", "error": str(error), "retryable": True}, + ensure_ascii=False, + ) except Exception as e: logger.error("get_events 失败: %s", e) - return json.dumps({"error": str(e)}) + return json.dumps( + {"status": "failure", "error": str(e), "retryable": True}, + ensure_ascii=False, + ) @mcp.tool() def get_sleep_context() -> str: """获取 Fitbit 睡眠判断上下文,供 proactive 作为 context 注入。""" + if _recording_backend(): + return json.dumps({"status": "empty"}, ensure_ascii=False) try: data = _fetch_agent_payload(timeout=5) - return json.dumps(_build_sleep_context(data), ensure_ascii=False) + return json.dumps( + {"status": "items", "items": [_build_sleep_context(data)]}, + ensure_ascii=False, + ) except requests.exceptions.ConnectionError: logger.warning("fitbit-monitor 未运行 (%s)", BASE_URL) return json.dumps( - _unavailable_sleep_context( - "Fitbit 睡眠判断当前不可用;即使可用,它也只是概率判断,不保证 100% 准确。" - ), + { + "status": "items", + "items": [ + _unavailable_sleep_context( + "Fitbit 睡眠判断当前不可用;即使可用,它也只是概率判断,不保证 100% 准确。" + ) + ], + }, ensure_ascii=False, ) except Exception as e: logger.error("get_sleep_context 失败: %s", e) return json.dumps( - _unavailable_sleep_context(f"Fitbit 睡眠判断拉取失败: {e}"), + { + "status": "items", + "items": [ + _unavailable_sleep_context(f"Fitbit 睡眠判断拉取失败: {e}") + ], + }, ensure_ascii=False, ) @@ -278,7 +311,9 @@ def acknowledge_events(event_ids: list[str]) -> str: JSON 对象,包含每个 ID 的处理结果。 """ if not event_ids: - return json.dumps({"acknowledged": [], "failed": []}) + return json.dumps({"status": "skipped", "reason": "no_ids"}) + if _recording_backend(): + raise RuntimeError("fitbit recording backend 不允许确认事件") acknowledged = [] failed = [] @@ -295,6 +330,16 @@ def acknowledge_events(event_ids: list[str]) -> str: logger.error("acknowledge %s 失败: %s", eid, e) failed.append(eid) - return json.dumps({"acknowledged": acknowledged, "failed": failed}) + payload = ( + {"status": "committed", "ids": acknowledged} + if not failed and acknowledged == event_ids + else { + "status": "failure", + "error": "Fitbit 事件未完整确认", + "retryable": True, + "failed_ids": failed, + } + ) + return json.dumps(payload, ensure_ascii=False) return mcp diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 5c8fb56..ad51565 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,6 +1,11 @@ from __future__ import annotations +from pathlib import Path +from typing import cast + import pytest +from agent.plugin_composition import DashboardContext +from fastapi.routing import APIRoute from fastapi import HTTPException import dashboard @@ -97,9 +102,23 @@ def monitor_json(path: str): return MONITOR_SNAPSHOT monkeypatch.setattr(dashboard, "_monitor_json", monitor_json) - dashboard.register(app, object(), object()) - overview_route = next( - route for route in app.routes if route.path == "/api/dashboard/fitbit/overview" + dashboard.register( + app, + DashboardContext( + plugin_id="fitbit", + plugin_dir=Path(dashboard.__file__).resolve().parent, + data_root=Path("/tmp/fitbit-dashboard-test"), + validation=False, + ), + ) + overview_route = cast( + APIRoute, + next( + route + for route in app.routes + if isinstance(route, APIRoute) + and route.path == "/api/dashboard/fitbit/overview" + ), ) assert overview_route.endpoint()["current"]["heart_rate"] == 72 diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py new file mode 100644 index 0000000..689096b --- /dev/null +++ b/tests/test_manager_integration.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path + +import pytest +from agent.plugins.generation_activity_host import ActivityHost +from agent.plugins.generation_proactive_host import ProactiveActivityAdapter +from agent.plugins.generation import PluginGeneration +from agent.plugins.manager import PluginManager +from agent.plugins.snapshot import RuntimeSnapshot +from bus.event_bus import EventBus + + +ROOT = Path(__file__).resolve().parents[1] + + +def _stage_plugin(tmp_path: Path) -> Path: + """复制可执行 artifact,并复用当前测试解释器的依赖环境。""" + + source = tmp_path / "plugins" / "fitbit" + shutil.copytree( + ROOT, + source, + ignore=shutil.ignore_patterns( + ".git", + ".pytest_cache", + "__pycache__", + "node_modules", + ), + ) + (source / ".venv").symlink_to( + Path(sys.executable).parent.parent, + target_is_directory=True, + ) + return source + + +@pytest.mark.asyncio +async def test_manager_rebuilds_fitbit_runtime_on_exact_formal_root( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证 formal boot 与 candidate 重建共享声明而不共享 Root owner。""" + + # 1. 正式启动真实 monitor/MCP handshake,但不调用 Fitbit 外部 API。 + plugin_root = _stage_plugin(tmp_path) + manager = PluginManager( + plugin_dirs=[plugin_root.parent], + event_bus=EventBus(), + tool_registry=None, + workspace=tmp_path / "workspace", + installed_cache_root=tmp_path / "home" / "cache", + ) + activity = ActivityHost( + (ProactiveActivityAdapter(manager.composition_generation_host),) + ) + manager.bind_activity_host(activity) + stable_snapshot = None + validation_root = None + try: + await manager.load_all() + stable_snapshot = manager.current_snapshot + assert stable_snapshot is not None + assert stable_snapshot.composition_root is not None + assert stable_snapshot.mcp_server_registry is not None + assert stable_snapshot.managed_process_registry is not None + assert stable_snapshot.proactive_component_catalog is not None + assert stable_snapshot.mobile_ui_registry is not None + stable_generation = next(iter(stable_snapshot.generations.values())) + stable_runtime = manager.composition_generation_host.get( + stable_generation.generation_id + ) + assert stable_runtime is not None and stable_runtime.mode == "formal" + assert stable_runtime.processes is not None + assert stable_runtime.processes.endpoint("monitor").port == 18765 + assert stable_runtime.mcp is not None + stable_route = stable_runtime.mcp.server("fitbit").route() + assert stable_route.mode == "formal" + await stable_route.aclose() + + # 2. 新版本先在隔离 Root 中验证,再重建 formal Root。 + for relative in ("plugin.py", "akashic.plugin.toml"): + path = plugin_root / relative + path.write_text( + path.read_text(encoding="utf-8").replace("3.0.0", "3.0.1"), + encoding="utf-8", + ) + candidate = await manager.prepare_candidate("fitbit") + assert candidate is not None and candidate.runtime_snapshot is not None + assert candidate.validation_workspace is not None + validation_root = candidate.validation_workspace.parent + candidate_snapshot = candidate.runtime_snapshot + assert candidate_snapshot.composition_root is not None + assert candidate_snapshot.proactive_component_catalog is not None + assert ( + candidate_snapshot.proactive_component_catalog.root_instance_token + is candidate_snapshot.composition_root.instance_token + ) + original_invariants = manager._post_publish_invariants # pyright: ignore[reportPrivateUsage] + candidate_checked = False + + async def inspect_candidate_runtime( + generation: PluginGeneration, + snapshot: RuntimeSnapshot, + ) -> None: + nonlocal candidate_checked + candidate_runtime = manager.composition_generation_host.get( + generation.generation_id + ) + assert candidate_runtime is not None + assert candidate_runtime.mode == "candidate" + assert candidate_runtime.mcp is not None + async with candidate_runtime.mcp.route("fitbit") as candidate_route: + assert set(candidate_route.tool_names) == { + "get_proactive_events", + "get_sleep_context", + } + proactive = await candidate_route.call("get_proactive_events", {}) + sleep = await candidate_route.call("get_sleep_context", {}) + assert json.loads(proactive.output) == {"status": "empty"} + assert json.loads(sleep.output) == {"status": "empty"} + with pytest.raises(PermissionError, match="未获 allowlist 授权"): + _ = await candidate_route.call( + "acknowledge_events", + {"event_ids": ["event-1"]}, + ) + candidate_checked = True + await original_invariants(generation, snapshot) + + monkeypatch.setattr( + manager, + "_post_publish_invariants", + inspect_candidate_runtime, + ) + result = await manager.publish_prepared("fitbit") + assert result["publication_state"] == "committed" + assert candidate_checked + final_snapshot = manager.current_snapshot + assert final_snapshot is not None and final_snapshot.composition_root is not None + assert final_snapshot.composition_root is not candidate_snapshot.composition_root + assert final_snapshot.proactive_component_catalog is not None + assert ( + final_snapshot.proactive_component_catalog.root_instance_token + is final_snapshot.composition_root.instance_token + ) + assert not validation_root.exists() + finally: + await manager.terminate_all() + + # 3. Manager 终止后进程、MCP、Activity 与 Root effects 全部归零。 + assert activity.active is None + assert stable_snapshot is not None and stable_snapshot.composition_root is not None + assert stable_snapshot.composition_root.receipt().effects == () + assert stable_snapshot.composition_root.topology_view().listeners == () diff --git a/tests/test_mcp_v3_runtime.py b/tests/test_mcp_v3_runtime.py new file mode 100644 index 0000000..4f278d1 --- /dev/null +++ b/tests/test_mcp_v3_runtime.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from typing import cast + +import pytest +from mcp.server.fastmcp.exceptions import ToolError + +from src import mcp_bridge + + +async def _call(name: str, arguments: dict[str, object]) -> dict[str, object]: + _, structured = await mcp_bridge.create_mcp_server().call_tool(name, arguments) + result = cast(dict[str, object], cast(object, structured)).get("result") + assert isinstance(result, str) + payload = json.loads(result) + assert isinstance(payload, dict) + return payload + + +@pytest.mark.asyncio +async def test_recording_backend_is_typed_empty_without_monitor_access( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FITBIT_BACKEND", "recording") + + def forbidden(*args: object, **kwargs: object) -> object: + raise AssertionError((args, kwargs)) + + monkeypatch.setattr(mcp_bridge.requests, "get", forbidden) + monkeypatch.setattr(mcp_bridge.requests, "post", forbidden) + + assert await _call("get_proactive_events", {}) == {"status": "empty"} + assert await _call("get_sleep_context", {}) == {"status": "empty"} + assert await _call("acknowledge_events", {"event_ids": []}) == { + "status": "skipped", + "reason": "no_ids", + } + with pytest.raises(ToolError, match="recording backend 不允许确认事件"): + _ = await _call("acknowledge_events", {"event_ids": ["event-1"]}) + + +@pytest.mark.asyncio +async def test_formal_fetch_and_ack_encode_explicit_results( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FITBIT_BACKEND", raising=False) + monkeypatch.setattr( + mcp_bridge, + "_fetch_agent_payload", + lambda timeout: { + "health_events": [ + { + "id": "event-1", + "type": "high_hr", + "message": "心率偏高", + "severity": "high", + } + ] + }, + ) + + class Response: + status_code = 200 + + @staticmethod + def json() -> dict[str, object]: + return {"acknowledged": True} + + monkeypatch.setattr(mcp_bridge.requests, "post", lambda *args, **kwargs: Response()) + + fetched = await _call("get_proactive_events", {}) + assert fetched["status"] == "items" + items = cast(list[dict[str, object]], fetched["items"]) + assert [item["event_id"] for item in items] == ["event-1"] + assert await _call("acknowledge_events", {"event_ids": ["event-1"]}) == { + "status": "committed", + "ids": ["event-1"], + } diff --git a/tests/test_migrate_v2_data.py b/tests/test_migrate_v2_data.py new file mode 100644 index 0000000..c69e4f6 --- /dev/null +++ b/tests/test_migrate_v2_data.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts import migrate_v2_data as migration + + +def _source(workspace: Path) -> Path: + source = workspace / "mcp" / "fitbit-mcp" / "monitor" + source.mkdir(parents=True) + (source / "monitor.config.toml").write_text("[server]\nport=18765\n") + (source / "tokens.json").write_text('{"access":"secret"}\n') + return source + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_process_failure_rolls_back_new_targets_and_retains_source( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + source = _source(workspace) + before = {path.name: _digest(path) for path in source.iterdir()} + original_replace = migration.os.replace + calls = 0 + + def fail_second(source_path: Path, target_path: Path) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("injected publish failure") + original_replace(source_path, target_path) + + monkeypatch.setattr(migration.os, "replace", fail_second) + + with pytest.raises(OSError, match="injected publish failure"): + _ = migration.migrate_v2_data(workspace, "github") + + target = workspace / "plugin-data" / "fitbit-github" + assert not target.exists() + assert {path.name: _digest(path) for path in source.iterdir()} == before + + +def test_process_crash_restarts_from_partial_publication( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + source = _source(workspace) + before = {path.name: _digest(path) for path in source.iterdir()} + repo = Path(__file__).resolve().parents[1] + core = Path(os.environ["AKASHIC_AGENT_ROOT"]) + code = """ +import os +from pathlib import Path +from scripts import migrate_v2_data as migration + +real_replace = migration.os.replace +calls = 0 +def crash_second(source, target): + global calls + calls += 1 + if calls == 2: + os._exit(137) + real_replace(source, target) +migration.os.replace = crash_second +migration.migrate_v2_data(Path(os.environ['FITBIT_TEST_WORKSPACE']), 'github') +""" + environment = { + **os.environ, + "AKASHIC_AGENT_ROOT": str(core), + "FITBIT_TEST_WORKSPACE": str(workspace), + "PYTHONPATH": os.pathsep.join((str(repo), str(core))), + } + crashed = subprocess.run( + [sys.executable, "-c", code], + cwd=repo, + env=environment, + check=False, + ) + assert crashed.returncode == 137 + + target = workspace / "plugin-data" / "fitbit-github" + assert (target / "monitor.config.toml").is_file() + assert not (target / ".fitbit-v2-migration.json").exists() + receipt = migration.migrate_v2_data(workspace, "github") + + assert receipt["source_retained"] is True + assert (target / ".fitbit-v2-migration.json").is_file() + assert {path.name: _digest(path) for path in source.iterdir()} == before + assert not list((workspace / "plugin-data").glob(".fitbit-v2-migrate-*")) + + +def test_invalid_receipt_identity_fails_without_touching_data(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + source = _source(workspace) + receipt = migration.migrate_v2_data(workspace, "github") + target = workspace / "plugin-data" / "fitbit-github" + before_source = {path.name: _digest(path) for path in source.iterdir()} + before_target = { + path.name: _digest(path) + for path in target.iterdir() + if path.name != ".fitbit-v2-migration.json" + } + receipt["target"] = "plugin-data/another-plugin" + (target / ".fitbit-v2-migration.json").write_text( + json.dumps(receipt), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="receipt 身份无效"): + _ = migration.migrate_v2_data(workspace, "github") + + assert {path.name: _digest(path) for path in source.iterdir()} == before_source + assert { + path.name: _digest(path) + for path in target.iterdir() + if path.name != ".fitbit-v2-migration.json" + } == before_target diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 98dc2a3..d9e0a79 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,53 +1,171 @@ from __future__ import annotations +import inspect +from pathlib import Path + import pytest +from agent.plugin_composition import ( + MANAGED_PROCESSES, + MCP_SERVERS, + PROACTIVE_COMPONENTS, + UI_SLOTS, + CompositionRoot, + PluginProactiveComponents, + PluginRuntime, + PluginUiSlots, +) +from agent.plugin_composition.mcp_slots import ( + PluginMcpServers, + _freeze_plugin_mcp_servers, +) +from agent.plugin_composition.proactive import _freeze_plugin_proactive_components +from agent.plugin_composition.process_slots import ( + PluginManagedProcesses, + _freeze_plugin_managed_processes, +) +from agent.plugins.composable import ComposablePlugin +from agent.plugins import manager as manager_module +from agent.plugins.static_manifest import load_static_plugin_manifest import plugin as plugin_module -from plugin import FitbitConfig, FitbitPlugin - +from plugin import FitbitConfig, _mobile_ui_query -def test_declares_mcp_and_both_proactive_channels() -> None: - plugin = FitbitPlugin() - plugin.context = type("Context", (), {"config": FitbitConfig()})() - - assert [server.name for server in plugin.mcp_servers()] == ["fitbit"] - services = plugin.managed_services() - assert [(service.id, service.cwd) for service in services] == [ - ("monitor", "monitor") - ] - sources = plugin.proactive_sources() - assert [source.id for source in sources] == ["health_alerts", "sleep_context"] - assert [source.channels for source in sources] == [("alert",), ("context",)] - assert all(not hasattr(source, "poll_interval_seconds") for source in sources) +ROOT = Path(__file__).resolve().parents[1] -def test_proactive_can_be_disabled() -> None: - plugin = FitbitPlugin() - plugin.context = type( - "Context", - (), - {"config": FitbitConfig.model_validate({"proactive": {"enabled": False}})}, - )() - assert plugin.proactive_sources() == [] +def test_pure_v3_exports_and_exact_apply() -> None: + assert plugin_module.api_version == 3 + assert plugin_module.name == "fitbit" + assert plugin_module.version == "3.0.0" + assert tuple(inspect.signature(plugin_module.apply).parameters) == ("ctx", "config") + assert ComposablePlugin.from_module(plugin_module).dashboard_module == "dashboard.py" -def test_declares_plugin_owned_mobile_health_panel() -> None: - contribution = FitbitPlugin.mobile_ui() - assert contribution.module == "mobile_panel.js" - assert contribution.stylesheet == "mobile_panel.css" - assert contribution.navigation.label == "健康状态" +@pytest.mark.asyncio +async def test_apply_registers_exact_runtime_sources_and_mobile_ui( + tmp_path: Path, +) -> None: + root = CompositionRoot("fitbit:test") + processes = PluginManagedProcesses(root.instance_token) + servers = PluginMcpServers(root.instance_token) + components = PluginProactiveComponents(root.instance_token) + ui_slots = PluginUiSlots() + await root.context.provide(MANAGED_PROCESSES, processes) + await root.context.provide(MCP_SERVERS, servers) + await root.context.provide(PROACTIVE_COMPONENTS, components) + await root.context.provide(UI_SLOTS, ui_slots) + data_dir = tmp_path / "plugin-data" + await root.mount( + ComposablePlugin.from_module(plugin_module), + name="fitbit", + runtime=PluginRuntime( + plugin_id="fitbit", + plugin_dir=ROOT, + data_dir=data_dir, + workspace=tmp_path / "workspace", + config=FitbitConfig(), + ), + ) + process = _freeze_plugin_managed_processes( + processes, + root.instance_token, + )["monitor"].definition + mcp = _freeze_plugin_mcp_servers( + servers, + root.instance_token, + )["fitbit"].definition + proactive = _freeze_plugin_proactive_components( + components, + root.instance_token, + {"fitbit": "fitbit:test"}, + ) + mobile = ui_slots.freeze()["fitbit"] + assert process.cwd == "." + assert process.port_env == "FITBIT_MONITOR_PORT" + assert mcp.candidate_env == {"FITBIT_BACKEND": "recording"} + assert [item.definition.name for item in proactive.sources.values()] == [ + "health_alerts", + "sleep_context", + ] + assert mobile.descriptor.navigation_label == "健康状态" + assert not data_dir.exists() + await root.dispose() + + +@pytest.mark.asyncio +async def test_disabled_proactive_omits_sources(tmp_path: Path) -> None: + root = CompositionRoot("fitbit:disabled") + processes = PluginManagedProcesses(root.instance_token) + servers = PluginMcpServers(root.instance_token) + components = PluginProactiveComponents(root.instance_token) + ui_slots = PluginUiSlots() + await root.context.provide(MANAGED_PROCESSES, processes) + await root.context.provide(MCP_SERVERS, servers) + await root.context.provide(PROACTIVE_COMPONENTS, components) + await root.context.provide(UI_SLOTS, ui_slots) + await root.mount( + ComposablePlugin.from_module(plugin_module), + name="fitbit", + runtime=PluginRuntime( + plugin_id="fitbit", + plugin_dir=ROOT, + data_dir=tmp_path / "plugin-data", + workspace=tmp_path / "workspace", + config=FitbitConfig.model_validate({"proactive": {"enabled": False}}), + ), + ) + catalog = _freeze_plugin_proactive_components( + components, + root.instance_token, + {"fitbit": "fitbit:disabled"}, + ) + assert catalog.sources == {} + await root.dispose() + + +def test_static_manifest_freezes_runtime_and_candidate_exclusions() -> None: + manifest = load_static_plugin_manifest(ROOT) + assert manifest.name == "fitbit" + assert manifest.version == "3.0.0" + assert manifest.requirements == ("requirements.txt",) + assert len(manifest.managed_processes) == 1 + assert manifest.managed_processes[0].formal_port == 18765 + assert manifest.mcp_servers[0].candidate_env == (("FITBIT_BACKEND", "recording"),) + assert "tokens.json" in manifest.exclude_data_paths + assert "monitor.config.local.toml" in manifest.exclude_data_paths + + +def test_candidate_copy_omits_fitbit_credentials(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + source = workspace / "plugin-data" / "fitbit-github" + target = tmp_path / "candidate-data" + source.mkdir(parents=True) + (source / "tokens.json").write_text('{"access":"candidate-secret"}\n') + (source / "monitor.config.local.toml").write_text("token='candidate-secret'\n") + (source / "mobile_sleep_projection.json").write_text('{"status":"ok"}\n') + manifest = load_static_plugin_manifest(ROOT) + + inventory = manager_module._copy_validation_data( # pyright: ignore[reportPrivateUsage] + source, + target, + manifest.exclude_data_paths, + ) -def test_declares_plugin_owned_dashboard_panel() -> None: - assert FitbitPlugin.dashboard_module() == "dashboard.py" + assert inventory == () + assert not (target / "tokens.json").exists() + assert not (target / "monitor.config.local.toml").exists() + assert b"candidate-secret" not in b"".join( + path.read_bytes() for path in target.rglob("*") if path.is_file() + ) def test_mobile_health_panel_uses_reader_and_rejects_unknown_methods( monkeypatch: pytest.MonkeyPatch, ) -> None: - current = {"current": {"heart_rate": 72}} - history = {"sleep_days": []} + current: dict[str, object] = {"current": {"heart_rate": 72}} + history: dict[str, object] = {"sleep_days": []} class Reader: def get_current(self) -> dict[str, object]: @@ -57,15 +175,13 @@ def get_sleep_history(self) -> dict[str, object]: return history monkeypatch.setattr(plugin_module, "FitbitMobileDashboardReader", Reader) - plugin = FitbitPlugin() - - current_result = plugin.mobile_ui_query( + current_result = _mobile_ui_query( "fitbit.current", {}, session_id=None, turn_id=None, ) - history_result = plugin.mobile_ui_query( + history_result = _mobile_ui_query( "fitbit.sleep_history", {}, session_id=None, @@ -74,7 +190,7 @@ def get_sleep_history(self) -> dict[str, object]: assert current_result == current assert history_result == history with pytest.raises(ValueError, match="未知 fitbit 移动方法"): - plugin.mobile_ui_query( + _mobile_ui_query( "fitbit.write", {}, session_id=None, From 5d1be85c1cffce781b07f30ada076b3e55b9eee2 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:12:59 +0800 Subject: [PATCH 2/5] fix(deps): align websockets with core --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 57102da..4d6e779 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ mcp==1.26.0 requests==2.32.5 fastapi==0.136.1 uvicorn[standard]==0.41.0 -websockets==16.0 +websockets>=15.0,<16 numpy==2.4.4 scikit-learn==1.8.0 scipy==1.17.1 From ac8d0a5e4c10247e42e74fffa57b5dc7555ca2df Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:19:27 +0800 Subject: [PATCH 3/5] test(plugin): exclude CI-only core checkout --- tests/test_manager_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py index 689096b..6a57bbd 100644 --- a/tests/test_manager_integration.py +++ b/tests/test_manager_integration.py @@ -26,6 +26,7 @@ def _stage_plugin(tmp_path: Path) -> Path: source, ignore=shutil.ignore_patterns( ".git", + ".akashic-core", ".pytest_cache", "__pycache__", "node_modules", From fadceedb36dce63c8dc3868dcdfce5138b2962d2 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:22:26 +0800 Subject: [PATCH 4/5] test(plugin): exclude the staged runtime environment --- tests/test_manager_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py index 6a57bbd..5206f46 100644 --- a/tests/test_manager_integration.py +++ b/tests/test_manager_integration.py @@ -28,6 +28,7 @@ def _stage_plugin(tmp_path: Path) -> Path: ".git", ".akashic-core", ".pytest_cache", + ".venv", "__pycache__", "node_modules", ), From eda9a879c751f4d2268a3dbc4c7b1f847de79f34 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 22 Aug 2026 01:25:20 +0800 Subject: [PATCH 5/5] ci(plugin): bind type checks to the v3 runtime --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 05cf36d..36e67fb 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -69,7 +69,7 @@ jobs: env: PYTHONPATH: .akashic-core run: >- - .venv/bin/basedpyright --level error + .venv/bin/pyright --pythonpath .venv/bin/python --level error plugin.py dashboard.py src/mcp_bridge.py scripts/migrate_v2_data.py tests/test_plugin.py tests/test_dashboard.py