feat(system): S90: ai_mail bidirectional cross-project email (DPLAN-0121 Phase 3) + reply-path delivery

Co-Authored-By: @devpulse <devpulse@aipass>
This commit is contained in:
AIOSAI
2026-04-11 18:15:37 -07:00
co-authored by @devpulse
parent e7950e76f9
commit 262bb7bb58
9 changed files with 433 additions and 83 deletions
+26 -1
View File
@@ -78,7 +78,7 @@
{
"file": "apps/handlers/email/reply.py",
"standard": "handlers",
"reason": "Imports users.branch_detection.get_branch_info_from_registry and registry.read.get_all_branches — same-branch cross-handler imports for sender identity and branch listing on reply."
"reason": "Same-branch cross-handler lazy imports: users.branch_detection.get_branch_info_from_registry, registry.read.get_all_branches (sender identity + branch listing), email.delivery.deliver_email_to_branch, email.inbox_cleanup.mark_as_closed_and_archive (reply delivery + close). All lazy-imported inside functions to avoid circular imports."
},
{
"file": "apps/handlers/email/send.py",
@@ -234,6 +234,31 @@
"file": "apps/handlers/users/branch_detection.py",
"standard": "handlers",
"reason": "Imports paths.find_repo_root — shared utility consolidated from 8 copies per DPLAN-0036."
},
{
"file": "tests/test_delivery.py",
"standard": "architecture",
"reason": "Test file lives in tests/ directory — not subject to 3-layer app structure rule."
},
{
"file": "tests/test_delivery.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to exercise internal functions (_migrate_inbox_format, _is_private_branch_email, _resolve_reply_path). Module entry-point-only rule does not apply to tests."
},
{
"file": "tests/test_send_identity.py",
"standard": "architecture",
"reason": "Test file lives in tests/ directory — not subject to 3-layer app structure rule."
},
{
"file": "tests/test_send_identity.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to exercise internal detection functions. Module entry-point-only rule does not apply to tests."
},
{
"file": "tests/test_send_identity.py",
"standard": "documentation",
"reason": "Test helper functions (_load_active_source, fixtures) are private/internal — docstring requirement does not apply to test infrastructure."
}
],
"notes": {
@@ -14,6 +14,7 @@ Independent handler - no module dependencies.
"""
import json
import os
import uuid
import subprocess
from pathlib import Path
@@ -130,6 +131,27 @@ def _is_private_branch_email(email: str) -> bool:
return False
def _resolve_reply_path() -> str:
"""Detect the caller's ai_mail inbox path from AIPASS_CALLER_CWD env var.
Used to store a reply_path on delivered messages so cross-project replies
can bypass registry lookup and write directly to the sender's inbox.
Returns the absolute path to the caller's inbox.json, or empty string if
AIPASS_CALLER_CWD is not set or no inbox directory is found.
"""
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "")
if not caller_cwd:
return ""
# Check the CWD itself and up to 5 parent directories for .ai_mail.local/
candidate = Path(caller_cwd)
for path in [candidate] + list(candidate.parents)[:5]:
inbox = path / ".ai_mail.local" / "inbox.json"
if inbox.exists():
return str(inbox)
return ""
def deliver_email_to_branch(
to_branch: str,
email_data: Dict,
@@ -251,6 +273,12 @@ def deliver_email_to_branch(
if email_data.get('dispatched_to'):
message["dispatched_to"] = email_data['dispatched_to']
# Store reply_path for cross-project replies.
# Pass-through from email_data, or auto-detect from AIPASS_CALLER_CWD.
reply_path = email_data.get("reply_path") or _resolve_reply_path()
if reply_path:
message["reply_path"] = reply_path
# Prepend message to inbox (newest first)
inbox_data["messages"].insert(0, message)
inbox_data["total_messages"] = len(inbox_data["messages"])
@@ -133,6 +133,12 @@ def send_reply(
break
if not target_branch:
# Fallback: cross-project delivery via reply_path stored at receive time
stored_reply_path = original_email.get("reply_path")
if stored_reply_path:
return _deliver_via_reply_path(
stored_reply_path, reply_email_data, from_branch_path, original_email
)
return False, f"Could not find branch for {reply_destination}", None
# Deliver the reply (pass email address, not path)
@@ -161,6 +167,78 @@ def send_reply(
return True, f"Reply sent to {reply_destination}, original closed", reply_id
def _deliver_via_reply_path(
reply_path: str,
reply_email_data: Dict,
from_branch_path: Path,
original_email: Dict,
) -> Tuple[bool, str, Optional[str]]:
"""Deliver reply directly to an external project's inbox via stored reply_path.
Used when the recipient is not in the AIPass registry (cross-project reply).
Writes directly to the inbox.json at the stored path, then saves to sent/
and closes the original.
Args:
reply_path: Absolute path to the target inbox.json (stored at receive time).
reply_email_data: The reply email dict to deliver.
from_branch_path: Path to the replying branch (for sent folder and close).
original_email: The original email being replied to (for close).
Returns:
Tuple of (success, message, reply_id or None)
"""
inbox_file = Path(reply_path)
if not inbox_file.exists():
return False, f"reply_path inbox not found: {reply_path}", None
try:
with open(inbox_file, "r", encoding="utf-8") as f:
inbox_data = json.load(f)
except Exception as e:
logger.warning("[reply] _deliver_via_reply_path read failed %s: %s", reply_path, e)
return False, f"Failed to read target inbox: {e}", None
reply_id = str(uuid.uuid4())[:8]
reply_email_data["id"] = reply_id
inbox_data.setdefault("messages", []).insert(0, reply_email_data)
inbox_data["total_messages"] = len(inbox_data["messages"])
new_count = sum(1 for m in inbox_data["messages"]
if m.get("status") == "new" or not m.get("read", False))
inbox_data["unread_count"] = new_count
try:
with open(inbox_file, "w", encoding="utf-8") as f:
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.warning("[reply] _deliver_via_reply_path write failed %s: %s", reply_path, e)
return False, f"Failed to write to target inbox: {e}", None
logger.info("[reply] Cross-project reply delivered to %s", reply_path)
# Save to sender's sent folder
sent_folder = from_branch_path / ".ai_mail.local" / "sent"
sent_folder.mkdir(parents=True, exist_ok=True)
sent_file = sent_folder / f"{reply_id}.json"
try:
with open(sent_file, "w", encoding="utf-8") as f:
json.dump(reply_email_data, f, indent=2)
except Exception as e:
logger.warning("[reply] failed to save sent copy: %s", e)
# Auto-close the original email
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import mark_as_closed_and_archive
original_id = original_email.get("id")
if original_id:
close_success, close_msg = mark_as_closed_and_archive(from_branch_path, original_id)
if not close_success:
return True, f"Reply sent (warning: original not closed: {close_msg})", reply_id
destination = reply_email_data.get("to", reply_path)
return True, f"Reply sent to {destination} via reply_path, original closed", reply_id
if __name__ == "__main__":
from aipass.cli.apps.modules import console
console.print("\n" + "="*70)
@@ -31,6 +31,31 @@ from aipass.ai_mail.apps.handlers.paths import find_repo_root
BRANCH_REGISTRY_PATH = find_repo_root() / "AIPASS_REGISTRY.json"
def _find_caller_registry() -> Optional[Path]:
"""Find the caller's AIPASS_REGISTRY.json by walking up from AIPASS_CALLER_CWD.
Used to resolve external project branches that aren't in the AIPass registry.
Skips the AIPass registry itself to avoid redundant double-lookup.
Returns:
Path to the caller's registry file, or None if not found or same as main registry.
"""
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "")
if not caller_cwd:
return None
candidate = Path(caller_cwd)
aipass_registry = BRANCH_REGISTRY_PATH.resolve()
for path in [candidate] + list(candidate.parents)[:10]:
registry = path / "AIPASS_REGISTRY.json"
if registry.exists():
try:
if registry.resolve() != aipass_registry:
return registry
except Exception as e:
logger.warning("[identity] _find_caller_registry() resolve failed for %s: %s", registry, e)
return None
def _get_branches_list(registry: dict) -> list:
"""Normalize branches from registry to a list of dicts.
@@ -111,23 +136,31 @@ def _lookup_branch_by_name(branch_name: str) -> Optional[Dict]:
Returns:
Dict with branch info from registry, or None if not found
"""
if not BRANCH_REGISTRY_PATH.exists():
return None
name_lower = branch_name.lower()
try:
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
if BRANCH_REGISTRY_PATH.exists():
try:
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
for branch in _get_branches_list(registry):
if branch.get("name", "").lower() == name_lower:
return branch
except Exception as e:
logger.warning("[identity] _lookup_branch_by_name(%s) failed: %s", branch_name, e)
name_lower = branch_name.lower()
for branch in _get_branches_list(registry):
if branch.get("name", "").lower() == name_lower:
return branch
# Fallback: caller's registry (external project branches not in AIPass registry)
caller_registry = _find_caller_registry()
if caller_registry:
try:
with open(caller_registry, 'r', encoding='utf-8') as f:
registry = json.load(f)
for branch in _get_branches_list(registry):
if branch.get("name", "").lower() == name_lower:
return branch
except Exception as e:
logger.warning("[identity] _lookup_branch_by_name(%s) caller registry %s failed: %s", branch_name, caller_registry, e)
return None
except Exception as e:
logger.warning("[identity] _lookup_branch_by_name(%s) failed: %s", branch_name, e)
return None
return None
def find_branch_root(start_path: Path) -> Optional[Path]:
@@ -170,32 +203,43 @@ def get_branch_info_from_registry(branch_path: Path) -> Optional[Dict]:
Returns:
Dict with branch info from registry, or None if not found
"""
if not BRANCH_REGISTRY_PATH.exists():
return None
branch_path_resolved = branch_path.resolve()
try:
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
if BRANCH_REGISTRY_PATH.exists():
try:
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
registry = json.load(f)
registry_dir = BRANCH_REGISTRY_PATH.parent
for branch in _get_branches_list(registry):
reg_path = Path(branch["path"])
if not reg_path.is_absolute():
reg_path = (registry_dir / reg_path).resolve()
else:
reg_path = reg_path.resolve()
if reg_path == branch_path_resolved:
return branch
except Exception as e:
logger.warning("[identity] get_branch_info_from_registry(%s) failed: %s", branch_path, e)
registry_dir = BRANCH_REGISTRY_PATH.parent
branch_path_resolved = branch_path.resolve()
# Fallback: caller's registry (external project branches not in AIPass registry)
caller_registry = _find_caller_registry()
if caller_registry:
try:
with open(caller_registry, 'r', encoding='utf-8') as f:
registry = json.load(f)
registry_dir = caller_registry.parent
for branch in _get_branches_list(registry):
reg_path = Path(branch["path"])
if not reg_path.is_absolute():
reg_path = (registry_dir / reg_path).resolve()
else:
reg_path = reg_path.resolve()
if reg_path == branch_path_resolved:
return branch
except Exception as e:
logger.warning("[identity] get_branch_info_from_registry(%s) caller registry failed: %s", branch_path, e)
# Search registry for matching path
for branch in _get_branches_list(registry):
reg_path = Path(branch["path"])
# Resolve relative paths against registry location, not CWD
if not reg_path.is_absolute():
reg_path = (registry_dir / reg_path).resolve()
else:
reg_path = reg_path.resolve()
if reg_path == branch_path_resolved:
return branch
return None
except Exception as e:
logger.warning("[identity] get_branch_info_from_registry(%s) failed: %s", branch_path, e)
return None
return None
if __name__ == "__main__":
+64
View File
@@ -19,6 +19,7 @@ import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
from aipass.ai_mail.apps.handlers.email.delivery import (
_migrate_inbox_format,
_is_private_branch_email,
_resolve_reply_path,
deliver_email_to_branch,
)
@@ -429,3 +430,66 @@ def test_deliver_path_input_unresolvable(repo_root, noop_inbox_lock):
assert success is False
assert "Could not resolve path" in error
# ---- _resolve_reply_path() tests ------------------------------
def test_resolve_reply_path_no_env(monkeypatch):
"""Returns empty string when AIPASS_CALLER_CWD is not set."""
monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False)
assert _resolve_reply_path() == ""
def test_resolve_reply_path_inbox_in_cwd(tmp_path, monkeypatch):
"""Returns inbox path when .ai_mail.local/inbox.json exists in caller CWD."""
inbox_dir = tmp_path / ".ai_mail.local"
inbox_dir.mkdir(parents=True)
inbox_file = inbox_dir / "inbox.json"
inbox_file.write_text("{}", encoding="utf-8")
monkeypatch.setenv("AIPASS_CALLER_CWD", str(tmp_path))
assert _resolve_reply_path() == str(inbox_file)
def test_resolve_reply_path_inbox_in_parent(tmp_path, monkeypatch):
"""Returns inbox path when .ai_mail.local/inbox.json exists in a parent of caller CWD."""
inbox_dir = tmp_path / ".ai_mail.local"
inbox_dir.mkdir(parents=True)
inbox_file = inbox_dir / "inbox.json"
inbox_file.write_text("{}", encoding="utf-8")
nested_cwd = tmp_path / "src" / "feature"
nested_cwd.mkdir(parents=True)
monkeypatch.setenv("AIPASS_CALLER_CWD", str(nested_cwd))
assert _resolve_reply_path() == str(inbox_file)
def test_resolve_reply_path_no_inbox(tmp_path, monkeypatch):
"""Returns empty string when no .ai_mail.local/inbox.json found in tree."""
monkeypatch.setenv("AIPASS_CALLER_CWD", str(tmp_path))
assert _resolve_reply_path() == ""
def test_deliver_stores_reply_path_from_env(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
"""reply_path env detection is stored on delivered message."""
branches = _setup_branch(tmp_path)
caller_project = tmp_path / "external_project"
inbox_dir = caller_project / ".ai_mail.local"
inbox_dir.mkdir(parents=True)
inbox_file = inbox_dir / "inbox.json"
inbox_file.write_text("{}", encoding="utf-8")
monkeypatch.setenv("AIPASS_CALLER_CWD", str(caller_project))
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
success, _ = deliver_email_to_branch("@target", _make_email_data())
assert success is True
inbox_file_target = Path(branches[0]["path"]) / ".ai_mail.local" / "inbox.json"
with open(inbox_file_target, "r", encoding="utf-8") as f:
inbox = json.load(f)
msg = inbox["messages"][0]
assert "reply_path" in msg
assert msg["reply_path"] == str(inbox_file)
@@ -41,6 +41,7 @@ from aipass.ai_mail.apps.handlers.users.branch_detection import (
find_branch_root,
get_branch_info_from_registry,
_lookup_branch_by_name,
_find_caller_registry,
)
from aipass.ai_mail.apps.handlers.email.send import resolve_sender_info
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args
@@ -634,3 +635,125 @@ class TestAntiRegression:
assert result["reply_to"] == "@flow"
assert result["recipients"] == ["@spawn"]
assert result["mode"] == "direct"
# ─── _find_caller_registry() tests ───────────────────────
class TestFindCallerRegistry:
"""Tests for the caller registry fallback used for external project branches."""
def test_returns_none_when_no_env(self, clean_env):
"""Returns None when AIPASS_CALLER_CWD is not set."""
result = _find_caller_registry()
assert result is None
def test_returns_none_when_no_registry_in_tree(self, clean_env, tmp_path):
"""Returns None when no AIPASS_REGISTRY.json found under AIPASS_CALLER_CWD."""
os.environ["AIPASS_CALLER_CWD"] = str(tmp_path)
result = _find_caller_registry()
assert result is None
def test_finds_registry_in_cwd(self, clean_env, tmp_path):
"""Returns registry path when AIPASS_REGISTRY.json exists in caller CWD."""
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text('{"branches": []}', encoding="utf-8")
os.environ["AIPASS_CALLER_CWD"] = str(tmp_path)
with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH",
tmp_path / "other" / "AIPASS_REGISTRY.json"):
result = _find_caller_registry()
assert result == registry
def test_finds_registry_in_parent(self, clean_env, tmp_path):
"""Returns registry path when found in a parent directory of caller CWD."""
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text('{"branches": []}', encoding="utf-8")
nested = tmp_path / "src" / "vera"
nested.mkdir(parents=True)
os.environ["AIPASS_CALLER_CWD"] = str(nested)
with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH",
tmp_path / "other" / "AIPASS_REGISTRY.json"):
result = _find_caller_registry()
assert result == registry
def test_skips_aipass_registry_itself(self, clean_env, tmp_path):
"""Returns None when found registry is the same as BRANCH_REGISTRY_PATH."""
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text('{"branches": []}', encoding="utf-8")
os.environ["AIPASS_CALLER_CWD"] = str(tmp_path)
with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", registry):
result = _find_caller_registry()
assert result is None
# ─── Caller registry fallback tests ──────────────────────
class TestCallerRegistryFallback:
"""Tests for branch lookup fallback to external project registry."""
def test_lookup_by_name_falls_back_to_caller_registry(self, clean_env, tmp_path):
"""_lookup_branch_by_name finds external branch via caller registry."""
caller_registry = tmp_path / "AIPASS_REGISTRY.json"
caller_registry.write_text(json.dumps({"branches": [
{"name": "VERA", "path": str(tmp_path / "vera"), "email": "@vera", "status": "active"}
]}), encoding="utf-8")
empty_aipass = tmp_path / "other" / "AIPASS_REGISTRY.json"
empty_aipass.parent.mkdir(parents=True)
empty_aipass.write_text('{"branches": []}', encoding="utf-8")
os.environ["AIPASS_CALLER_CWD"] = str(tmp_path)
with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", empty_aipass):
result = _lookup_branch_by_name("vera")
assert result is not None
assert result["email"] == "@vera"
assert result["name"] == "VERA"
def test_lookup_by_name_prefers_aipass_registry(self, clean_env, tmp_path):
"""_lookup_branch_by_name returns AIPass result first when branch exists in both."""
aipass_registry = tmp_path / "AIPASS_REGISTRY.json"
aipass_registry.write_text(json.dumps({"branches": [
{"name": "SPAWN", "path": str(tmp_path / "spawn"), "email": "@spawn-aipass", "status": "active"}
]}), encoding="utf-8")
caller_dir = tmp_path / "external"
caller_dir.mkdir()
caller_registry = caller_dir / "AIPASS_REGISTRY.json"
caller_registry.write_text(json.dumps({"branches": [
{"name": "SPAWN", "path": str(tmp_path / "other_spawn"), "email": "@spawn-external", "status": "active"}
]}), encoding="utf-8")
os.environ["AIPASS_CALLER_CWD"] = str(caller_dir)
with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", aipass_registry):
result = _lookup_branch_by_name("spawn")
assert result is not None
assert result["email"] == "@spawn-aipass"
def test_get_branch_info_falls_back_to_caller_registry(self, clean_env, tmp_path):
"""get_branch_info_from_registry finds external branch path via caller registry."""
vera_dir = tmp_path / "vera_studio" / "src" / "vera"
vera_dir.mkdir(parents=True)
caller_registry = tmp_path / "vera_studio" / "AIPASS_REGISTRY.json"
caller_registry.write_text(json.dumps({"branches": [
{"name": "VERA", "path": str(vera_dir), "email": "@vera", "status": "active"}
]}), encoding="utf-8")
empty_aipass = tmp_path / "AIPASS_REGISTRY.json"
empty_aipass.write_text('{"branches": []}', encoding="utf-8")
os.environ["AIPASS_CALLER_CWD"] = str(tmp_path / "vera_studio" / "src" / "vera")
with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", empty_aipass):
result = get_branch_info_from_registry(vera_dir)
assert result is not None
assert result["email"] == "@vera"
@@ -437,8 +437,13 @@ class BranchDetector:
self.log_map[name] = branch_name
return branch_name
# Fallback: split on underscore for branches not in registry
# Log files follow pattern: branch_operation.log
# Full path: use path detection before falling back to stem splitting.
# This ensures ai_mail/logs/mail_*.log resolves to AI_MAIL via branch_map
# rather than returning a truncated stem like MAIL.
if '/' in log_file:
return self.detect_from_path(log_file)
# Bare filename: fallback to stem splitting
if '_' in name:
parts = name.split('_')
first_part = parts[0].upper()
@@ -451,10 +456,6 @@ class BranchDetector:
self.log_map[name] = name_upper
return name_upper
# If we have a full path, try path detection
if '/' in log_file:
return self.detect_from_path(log_file)
logger.info(f"Could not detect branch from log: {log_file}")
return 'UNKNOWN'
@@ -29,13 +29,13 @@ from aipass.prax.apps.modules.logger import get_direct_logger
logger = get_direct_logger()
# Trigger integration (graceful fallback if unavailable)
_trigger_available = False
try:
from aipass.trigger.apps.modules.core import trigger
_trigger_available = True
except ImportError as e:
logger.info(f"[monitor] trigger module not available, falling back: {e}")
trigger = None # type: ignore[assignment]
_trigger_available = False
# Monitoring subsystem imports
from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent, MonitoringQueue
@@ -128,10 +128,11 @@ class MonitoringFileHandler(FileSystemEventHandler):
def _fire_trigger(self, event_name: str, **kwargs):
"""Fire a trigger event for cross-module integration."""
if not _trigger_available:
return
try:
trigger.fire(event_name, **kwargs) # type: ignore[union-attr]
from aipass.trigger.apps.modules.core import trigger as _trigger
_trigger.fire(event_name, **kwargs)
except ImportError:
pass # trigger not available — silently skip
except Exception as e:
logger.warning(f"[monitor] trigger.fire('{event_name}') failed: {e}")
@@ -209,6 +210,16 @@ class MonitoringFileHandler(FileSystemEventHandler):
idx = parts.index('aipass')
if idx + 1 < len(parts):
return parts[idx + 1].upper()
# External project under ~/Projects/: use branch_detector for full label
# e.g. ~/Projects/AIPL/src/polyglot/ → AIPL/POLYGLOT
projects_base = Path.home() / 'Projects'
try:
Path(cwd).relative_to(projects_base)
result = detect_branch_from_path(cwd)
if result and result != 'UNKNOWN':
return result
except ValueError:
logger.info(f"[monitor] CWD not under ~/Projects/: {cwd}")
# Fallback: check src/{name} for branches outside aipass namespace
if 'src' in parts:
idx = parts.index('src')
+11 -35
View File
@@ -9,41 +9,13 @@
"""
PRAX Monitor Module - Mission Control for Autonomous Branches
Unified monitoring orchestrator that provides real-time visibility into:
- File changes across all branches (file watcher)
- Log events from all modules (log monitoring)
- Branch activity and state changes
- Module execution tracking
- System health and status
Purpose:
Single command interface for monitoring all autonomous branch activity.
Replaces fragmented monitoring with unified Mission Control console.
Enables multi-agent workflow visibility and coordination.
Thin orchestration layer for real-time monitoring of file changes, log events,
and agent activity across all AIPass branches. Delegates to handlers in
apps/handlers/monitoring/ (unified_stream, branch_detector, event_queue, etc.)
Usage:
drone @prax monitor # Show introspection
drone @prax monitor run # Monitor all branches
drone @prax monitor run seedgo,cli # Monitor specific branches
Interactive Commands:
help # Show available commands
status # Display current monitoring state
filter [branches] # Adjust branch filter
quit/exit # Stop monitoring
Architecture:
This module is thin orchestration layer only. All implementation
delegated to specialized handlers in apps/handlers/monitoring/:
- unified_stream.py → Terminal output formatting
- branch_detector.py → Path-to-branch mapping
- interactive_filter.py → Runtime filter adjustment
- monitoring_filters.py → Event filtering logic
- event_queue.py → Event buffering and deduplication
- module_tracker.py → Module execution tracking
- filesystem_handler.py → Real-time file change detection (FileSystemEventHandler)
- log_watcher.py → Log stream processing
"""
import signal
@@ -227,13 +199,17 @@ def _run_monitor(args: List[str]) -> bool:
# Start monitoring threads
_start_threads()
# Enter interactive mode
_interactive_loop()
try:
_interactive_loop()
except KeyboardInterrupt:
logger.info("[monitor] KeyboardInterrupt escaped interactive loop")
console.print("\n[yellow]Monitoring stopped.[/yellow]")
# Cleanup on exit
_stop_threads()
return True
# sys.exit(0) prevents drone's post-execution json_handler from running
# after the monitor exits, avoiding a json.load crash on Ctrl+C.
sys.exit(0)
def _start_threads():