ReMe/reme2/mcp/test/_helpers.py
huangsen dd2de16481 ```
feat(file_watcher): add directory deletion support with descendant indexing

Add support for deleting entire directories and their indexed descendants
in the file watcher. Previously only individual file deletions were
handled properly. Now when a directory is deleted, the system finds all
indexed files beneath that directory path and removes them along with
their metadata and chunks.

The implementation includes:
- New `_descendant_indexed_paths` method to find all indexed files
  under a given directory path
- Updated `_on_deleted` method to process both the target path and
  all its indexed descendants
- Proper handling of symlinks and path resolution differences
- Enhanced logging to show directory deletion with child count

Also adds necessary os import for path operations.

refactor(config): restructure configuration profiles for clarity

Rename curated.yaml to remove outdated configuration file and
rename full.yaml to expert.yaml with updated documentation.
Add new service.yaml configuration profile that provides a
service-aligned MCP surface with three main tools:
- retrieve: graph-aware hybrid retrieval
- remember: single write entry point with log/distill modes
- maintain: vault hygiene sweep

The expert configuration now excludes the ingest tool since
cold-path operations are handled by external agents, and adds
memory_lint tool for structural issue detection.

Updated documentation to clarify the different configuration
profiles and their intended usage patterns.
```
2026-05-11 10:52:25 +08:00

162 lines
5.5 KiB
Python

"""Shared fixtures for the MCP profile tests.
Each suite uses one app per profile (boot cost ~2s) and a fresh temp
vault. The app is configured with `service.backend = http` so the
stdio MCP listener never starts — jobs are invoked directly via
`app.run_job`.
"""
from __future__ import annotations
import asyncio
import json
import sys
from dataclasses import dataclass
from pathlib import Path
# Make the repo importable when this module is loaded standalone
# (`python -m reme2.mcp.test` already has the path; direct imports
# from a fresh interpreter need this guard).
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
# Eager-import side-effect modules so all @R.register() decorators run
# before Application introspects the registry.
import reme2 # noqa: E402,F401
import reme2.mcp.steps # noqa: E402,F401
import reme2.memory # noqa: E402,F401 -- registers retriever + maintainer
import reme2.memory.ingestor # noqa: E402,F401
import reme2.memory.summarizer # noqa: E402,F401
from reme2.application import Application # noqa: E402
from reme2.config import parse_args # noqa: E402
CONFIG_DIR = _REPO_ROOT / "reme2" / "config"
PROFILES = {
"expert": CONFIG_DIR / "expert.yaml",
"service": CONFIG_DIR / "service.yaml",
}
# How many seed files `seed_vault` writes — checks that need to wait
# for indexing budget against this baseline.
SEED_FILE_COUNT = 3
@dataclass
class AppContext:
"""Bundle of everything a test function needs."""
app: Application
vault: Path
jobs: list[str]
@property
def watcher(self):
return self.app.context.components["file_watcher"]["default"]
@property
def file_store(self):
return self.watcher.file_store
def abs_path(self, *parts: str) -> str:
return str((self.vault.joinpath(*parts)).resolve())
def seed_vault(vault: Path) -> None:
"""Drop a small connected topic graph (Alice ↔ Bob ↔ Project X)."""
(vault / "topics" / "Alice").mkdir(parents=True, exist_ok=True)
(vault / "topics" / "Alice" / "Alice.md").write_text(
"---\ntitle: Alice\ncategory: profile\ntags: [person]\n---\n"
"# Alice\n\nAlice works on [[Project X]] with [[Bob]].\n"
"[author:: [[Alice]]]\n",
encoding="utf-8",
)
(vault / "topics" / "Bob").mkdir(parents=True, exist_ok=True)
(vault / "topics" / "Bob" / "Bob.md").write_text(
"---\ntitle: Bob\ncategory: profile\ntags: [person]\n---\n"
"# Bob\n\nBob collaborates with [[Alice]] on [[Project X]].\n",
encoding="utf-8",
)
(vault / "topics" / "Project X").mkdir(parents=True, exist_ok=True)
(vault / "topics" / "Project X" / "Project X.md").write_text(
"---\ntitle: Project X\ncategory: concept\n---\n"
"# Project X\n\nA major initiative led by [[Alice]] with [[Bob]].\n",
encoding="utf-8",
)
async def wait_for_index(watcher, expected_min: int, timeout_s: float = 15.0) -> None:
"""Poll `len(file_store)` until it reaches `expected_min` and stays
stable across 4 ticks. Raises if the budget runs out — a watcher
that never indexed seed files is a hard wiring failure, not a
soft check."""
deadline = asyncio.get_event_loop().time() + timeout_s
last = -1
stable = 0
while asyncio.get_event_loop().time() < deadline:
now = len(watcher.file_store)
if now == last and now >= expected_min:
stable += 1
if stable >= 4:
return
else:
stable = 0
last = now
await asyncio.sleep(0.25)
raise RuntimeError(
f"watcher did not reach >= {expected_min} files within {timeout_s}s "
f"(last seen: {last})"
)
def decode(resp) -> object:
"""Job answers come back as JSON strings — decode for inspection.
Pass through dicts / lists if the step already returned native types."""
if isinstance(resp.answer, (dict, list)):
return resp.answer
if isinstance(resp.answer, str):
try:
return json.loads(resp.answer)
except (json.JSONDecodeError, TypeError):
return resp.answer
return resp.answer
async def build_app(profile_path: Path, vault: Path) -> Application:
"""Boot an Application against `vault` using the given profile.
Forces `service.backend = http` so the stdio MCP listener never
starts (we only call jobs directly), and rewrites the watcher /
file_store paths to point at the temp vault.
"""
_, cfg = parse_args(
"start",
f"config={profile_path}",
f"components.file_watcher.default.watch_path={vault}",
f"components.file_store.default.db_path={vault}/.reme",
)
cfg["service"] = {"backend": "http"}
app = Application(**cfg)
await app.start()
return app
async def make_context(profile: str) -> tuple[AppContext, Path]:
"""Build a fresh temp vault + app for `profile`, wait for indexing.
The caller owns the temp dir lifecycle — returns its Path so it can
be `shutil.rmtree`d after `app.close()`.
"""
import tempfile
tmp = Path(tempfile.mkdtemp(prefix=f"reme2-mcp-{profile}-"))
vault = tmp / "vault"
vault.mkdir()
seed_vault(vault)
app = await build_app(PROFILES[profile], vault)
await wait_for_index(app.context.components["file_watcher"]["default"],
expected_min=SEED_FILE_COUNT)
jobs = sorted(app.context.jobs.keys())
return AppContext(app=app, vault=vault, jobs=jobs), tmp