From 4413df63ea0b7baf5c7e20d08c2c72efe5ee437a Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 22 Apr 2026 23:01:21 -0700 Subject: [PATCH] feat(memory): fix(memory): rollover detector+watcher scan external project registries (DPLAN-0146) Co-Authored-By: @memory --- .../memory/apps/handlers/monitor/detector.py | 58 ++++++++++++++----- .../apps/handlers/monitor/memory_watcher.py | 51 +++++++++++----- src/aipass/memory/tests/test_detector.py | 5 ++ 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/aipass/memory/apps/handlers/monitor/detector.py b/src/aipass/memory/apps/handlers/monitor/detector.py index bbc06ddf..9532a955 100644 --- a/src/aipass/memory/apps/handlers/monitor/detector.py +++ b/src/aipass/memory/apps/handlers/monitor/detector.py @@ -46,6 +46,25 @@ def _find_repo_root() -> Path: _REPO_ROOT = _find_repo_root() +def _find_caller_registries() -> List[Path]: + """Find project registries reachable from CWD (for external projects).""" + import os + + caller_cwd = ( + Path(os.environ.get("AIPASS_CALLER_CWD", "")).resolve() if os.environ.get("AIPASS_CALLER_CWD") else Path.cwd() + ) + aipass_registry = (_REPO_ROOT / "AIPASS_REGISTRY.json").resolve() + + registries = [] + for parent in [caller_cwd] + list(caller_cwd.parents): + for reg in parent.glob("*_REGISTRY.json"): + if reg.resolve() != aipass_registry: + registries.append(reg) + if registries: + break + return registries + + # ============================================================================= # DATA STRUCTURES # ============================================================================= @@ -74,17 +93,8 @@ class RolloverTrigger: # ============================================================================= -def _read_registry() -> List[Dict[str, Any]]: - """ - Read AIPASS_REGISTRY.json from repo root. - - Registry paths are relative — resolved against repo root. - - Returns: - List of branch dictionaries with absolute paths - """ - registry_path = _REPO_ROOT / "AIPASS_REGISTRY.json" - +def _read_single_registry(registry_path: Path, root: Path) -> List[Dict[str, Any]]: + """Read branches from a single registry file, resolving paths against root.""" if not registry_path.exists(): return [] @@ -93,20 +103,40 @@ def _read_registry() -> List[Dict[str, Any]]: data = json.load(f) branches = data.get("branches", []) - # Resolve relative paths against repo root for branch in branches: raw_path = branch.get("path", "") resolved = Path(raw_path) if not resolved.is_absolute(): - resolved = _REPO_ROOT / raw_path + resolved = root / raw_path branch["path"] = str(resolved) return branches except Exception as e: - logger.warning(f"[detector] Failed to read registry: {e}") + logger.warning(f"[detector] Failed to read registry {registry_path}: {e}") return [] +def _read_registry() -> List[Dict[str, Any]]: + """ + Read all project registries (AIPass + external projects from caller CWD). + + Registry paths are relative — resolved against their respective project root. + + Returns: + List of branch dictionaries with absolute paths + """ + branches = _read_single_registry(_REPO_ROOT / "AIPASS_REGISTRY.json", _REPO_ROOT) + + seen_paths = {b.get("path") for b in branches} + for reg_path in _find_caller_registries(): + for branch in _read_single_registry(reg_path, reg_path.parent): + if branch.get("path") not in seen_paths: + branches.append(branch) + seen_paths.add(branch.get("path")) + + return branches + + def _get_memory_file_path(branch: Dict, memory_type: str) -> Path | None: """ Get path to memory file for branch. diff --git a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py index 6427225b..ea26d58b 100644 --- a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py +++ b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py @@ -429,20 +429,10 @@ def _find_repo_root() -> Path: return Path.cwd() -def _get_branch_paths() -> list[Path]: - """ - Get all branch paths from AIPASS_REGISTRY.json (silent - no logging). - - Registry paths are relative — resolved against repo root. - - Returns: - List of Path objects for each branch - """ +def _paths_from_registry(registry_path: Path, root: Path) -> list[Path]: + """Read branch paths from a single registry file.""" import json - repo_root = _find_repo_root() - registry_path = repo_root / "AIPASS_REGISTRY.json" - if not registry_path.exists(): return [] @@ -456,16 +446,49 @@ def _get_branch_paths() -> list[Path]: raw_path = branch.get("path", "") branch_path = Path(raw_path) if not branch_path.is_absolute(): - branch_path = repo_root / raw_path + branch_path = root / raw_path if branch_path.exists(): paths.append(branch_path) return paths except Exception as e: - logger.warning(f"[memory_watcher] Failed to read branch paths from registry: {e}") + logger.warning(f"[memory_watcher] Failed to read registry {registry_path}: {e}") return [] +def _get_branch_paths() -> list[Path]: + """ + Get all branch paths from AIPass registry + external project registries. + + Returns: + List of Path objects for each branch + """ + import os + + repo_root = _find_repo_root() + paths = _paths_from_registry(repo_root / "AIPASS_REGISTRY.json", repo_root) + seen = {p.resolve() for p in paths} + + caller_cwd = ( + Path(os.environ.get("AIPASS_CALLER_CWD", "")).resolve() if os.environ.get("AIPASS_CALLER_CWD") else Path.cwd() + ) + aipass_registry = (repo_root / "AIPASS_REGISTRY.json").resolve() + + found_external = False + for parent in [caller_cwd] + list(caller_cwd.parents): + for reg in parent.glob("*_REGISTRY.json"): + if reg.resolve() != aipass_registry: + found_external = True + for p in _paths_from_registry(reg, reg.parent): + if p.resolve() not in seen: + paths.append(p) + seen.add(p.resolve()) + if found_external: + break + + return paths + + def _is_memory_file(file_path: Path) -> bool: """ Check if file is a memory file in .trinity/ (local.json or observations.json) diff --git a/src/aipass/memory/tests/test_detector.py b/src/aipass/memory/tests/test_detector.py index 71951440..c8ab6117 100644 --- a/src/aipass/memory/tests/test_detector.py +++ b/src/aipass/memory/tests/test_detector.py @@ -316,6 +316,7 @@ class TestReadRegistry: # Point _REPO_ROOT at our tmp_path monkeypatch.setattr(detector, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(detector, "_find_caller_registries", lambda: []) result = detector._read_registry() @@ -329,6 +330,7 @@ class TestReadRegistry: # Point _REPO_ROOT at a directory with no registry file monkeypatch.setattr(detector, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(detector, "_find_caller_registries", lambda: []) result = detector._read_registry() @@ -341,6 +343,7 @@ class TestReadRegistry: from aipass.memory.apps.handlers.monitor import detector monkeypatch.setattr(detector, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(detector, "_find_caller_registries", lambda: []) result = detector._read_registry() @@ -358,6 +361,7 @@ class TestReadRegistry: from aipass.memory.apps.handlers.monitor import detector monkeypatch.setattr(detector, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(detector, "_find_caller_registries", lambda: []) result = detector._read_registry() @@ -373,6 +377,7 @@ class TestReadRegistry: from aipass.memory.apps.handlers.monitor import detector monkeypatch.setattr(detector, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(detector, "_find_caller_registries", lambda: []) result = detector._read_registry()