feat(system): feat(system): S82: all branch audit fixes + prax 100% + drone backup removed + seedgo --help + ai_mail cascade fix + memory rollover + spawn template_owners + flow fixes + Medic investigation (#210)
* feat(system): feat(system): S82: core 10 cleanup — AGENTS/GEMINI prompts, STATUS.local, sandbox + vision docs, branch fixes Co-Authored-By: @devpulse <devpulse@aipass> * feat(system): feat(system): S82: all branch audit fixes + prax 100% + drone backup removed + seedgo --help + ai_mail cascade fix + memory rollover + spawn template_owners + flow fixes + Medic investigation Co-Authored-By: @devpulse <devpulse@aipass> --------- Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
@@ -39,7 +39,7 @@ src/aipass/{name}/
|
||||
~/.secrets/aipass/ # API keys, tokens, credentials (outside repo, cross-platform)
|
||||
```
|
||||
|
||||
**15 branches:** drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, devpulse, backup, daemon, memory, commons, skills
|
||||
**10 core branches:** drone, seedgo, prax, cli, flow, ai_mail, trigger, spawn, memory, devpulse
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ src/aipass/{name}/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
15 branches: drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, devpulse, backup, daemon, memory, commons, skills
|
||||
10 core branches: drone, seedgo, prax, cli, flow, ai_mail, trigger, spawn, memory, devpulse
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ src/aipass/{name}/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
15 branches: drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, devpulse, backup, daemon, memory, commons, skills
|
||||
10 core branches: drone, seedgo, prax, cli, flow, ai_mail, trigger, spawn, memory, devpulse
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ apps/
|
||||
modules/
|
||||
email.py # Orchestrates send/inbox/view/close/reply/sent/contacts
|
||||
dispatch.py # Orchestrates dispatch (send + wake combined)
|
||||
branch_ping.py # Memory health monitoring
|
||||
handlers/
|
||||
paths.py # Shared find_repo_root() utility
|
||||
central_writer.py # Central inbox stats aggregation
|
||||
@@ -57,13 +56,9 @@ apps/
|
||||
dashboard_sync.py # Dashboard integration
|
||||
registry/
|
||||
read.py # Registry reading + get_all_branches()
|
||||
load.py # Registry file loading
|
||||
update.py # Registry health updates
|
||||
users/
|
||||
user.py # Current user detection (get_current_user)
|
||||
branch_detection.py # CWD/env-based branch identity detection
|
||||
monitoring/
|
||||
memory.py # Memory file health checks
|
||||
json_utils/
|
||||
json_handler.py # Auto-creating JSON system
|
||||
```
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
"standard": "deep_nesting",
|
||||
"reason": "handle_command() depth 4 — entry point router, nested subcommand dispatch is inherent to the pattern"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/branch_ping.py",
|
||||
"standard": "deep_nesting",
|
||||
"reason": "handle_command() depth 4 — command routing with multiple subcommands, same dispatch pattern"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/daemon.py",
|
||||
"standard": "deep_nesting",
|
||||
@@ -190,11 +185,6 @@
|
||||
"standard": "documentation",
|
||||
"reason": "spawn_agent() has a full docstring — seedgo AST detection mismatch on multiline function signature."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/registry/update.py",
|
||||
"standard": "documentation",
|
||||
"reason": "ping_registry() has a full docstring — seedgo AST detection mismatch on multiline function signature."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/email.py",
|
||||
"standard": "deep_nesting",
|
||||
@@ -215,16 +205,6 @@
|
||||
"standard": "deep_nesting",
|
||||
"reason": "calculate_age() depth 4 — timestamp parsing with multiple fallback formats"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/registry/update.py",
|
||||
"standard": "deep_nesting",
|
||||
"reason": "_calculate_statistics() depth 5 — JSON traversal for registry stats with nested key checking"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/monitoring/errors.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Archived — file moved to .archive/errors(disabled).py. No longer in active codebase."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/central_writer.py",
|
||||
"standard": "handlers",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
---
|
||||
|
||||
**Status:** Operational. Core email workflow (send/inbox/reply/close), dispatch system (with startup timeout + auto-retry), daemon, desktop notifications all working. Seedgo 99%. 322 tests.
|
||||
**Status:** Operational. Core email workflow (send/inbox/reply/close), dispatch system (with startup timeout + auto-retry), daemon, desktop notifications all working. Seedgo 99%.
|
||||
|
||||
## Commands / Usage
|
||||
|
||||
@@ -44,8 +44,6 @@ The `dispatch` command sends an email and wakes the target branch in one step. A
|
||||
- Startup health check: monitors JSONL session files for 90s, kills if no activity
|
||||
- Auto-retry: 3 strikes (resume, resume, fresh) before bounce
|
||||
- Failed agents trigger bounce emails back to sender
|
||||
- `ping` commands exit 0 but produce no visible output in drone context *(partial)*
|
||||
|
||||
## Architecture
|
||||
|
||||
Follows the standard AIPass 3-layer pattern:
|
||||
@@ -57,14 +55,12 @@ ai_mail/
|
||||
│ ├── modules/
|
||||
│ │ ├── email.py # Send, inbox, view, reply, close, contacts
|
||||
│ │ ├── dispatch.py # Dispatch status, daemon, wake
|
||||
│ │ └── branch_ping.py # Branch health monitoring
|
||||
│ └── handlers/
|
||||
│ ├── email/ # Delivery, formatting, inbox ops, purge, reply
|
||||
│ ├── dispatch/ # Daemon, wake, dispatch_monitor, status
|
||||
│ ├── registry/ # Branch registry read/update/load
|
||||
│ ├── registry/ # Branch registry read
|
||||
│ ├── users/ # Branch detection, user lookup
|
||||
│ ├── json_utils/ # JSON I/O helpers (load_json, save_json)
|
||||
│ ├── monitoring/ # Memory health, error tracking
|
||||
│ ├── paths.py # Shared find_repo_root() utility
|
||||
│ ├── notify.py # Desktop notifications (dbus)
|
||||
│ └── central_writer.py # Central inbox stats aggregation
|
||||
|
||||
@@ -65,8 +65,6 @@ COMMANDS:
|
||||
close - Close email(s) without reply (archives)
|
||||
sent - View sent messages
|
||||
contacts - Manage contacts
|
||||
ping - Memory health check
|
||||
|
||||
EMAIL LIFECYCLE (v2):
|
||||
new → opened → closed
|
||||
- new: Just arrived, never viewed
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
Monitoring Handlers - AI Mail Monitoring Domain
|
||||
|
||||
Independent handlers for monitoring operations:
|
||||
- memory.py: Memory health checking and line counting
|
||||
- errors.py: Error log parsing and deduplication
|
||||
- status.py: Status determination and reporting
|
||||
|
||||
All handlers are independent (no cross-domain imports).
|
||||
"""
|
||||
@@ -1,141 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: memory.py
|
||||
# Description: Memory Health Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2025-11-15
|
||||
# Modified: 2025-11-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Memory Health Handler
|
||||
|
||||
Independent handler for memory file monitoring logic.
|
||||
Provides functions for counting file lines and determining health status.
|
||||
|
||||
Architecture:
|
||||
- No cross-domain imports (independent handler)
|
||||
- Provides: line counting, status determination
|
||||
- Used by: monitoring modules
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
# Health status thresholds
|
||||
THRESHOLD_GREEN_MAX = 400
|
||||
THRESHOLD_YELLOW_MIN = 401
|
||||
THRESHOLD_YELLOW_MAX = 550
|
||||
THRESHOLD_RED_MIN = 551
|
||||
THRESHOLD_EMAIL_TRIGGER = 600
|
||||
|
||||
# Status indicators
|
||||
STATUS_GREEN = "🟢 Healthy"
|
||||
STATUS_YELLOW = "🟡 Approaching"
|
||||
STATUS_RED = "🔴 Compress Now"
|
||||
|
||||
# =============================================
|
||||
# CORE FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def count_file_lines(file_path: Path | str) -> int:
|
||||
"""
|
||||
Count lines in a file
|
||||
|
||||
Args:
|
||||
file_path: Path to file to count
|
||||
|
||||
Returns:
|
||||
Number of lines in file, or 0 if file doesn't exist or error
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return len(f.readlines())
|
||||
except Exception as e:
|
||||
logger.warning("[memory] Failed to count lines in %s: %s", file_path, e)
|
||||
return 0
|
||||
|
||||
|
||||
def get_status_from_count(line_count: int) -> str:
|
||||
"""
|
||||
Determine health status from line count
|
||||
|
||||
Args:
|
||||
line_count: Number of lines in file
|
||||
|
||||
Returns:
|
||||
Status string with emoji indicator
|
||||
"""
|
||||
if line_count <= THRESHOLD_GREEN_MAX:
|
||||
return STATUS_GREEN
|
||||
elif THRESHOLD_YELLOW_MIN <= line_count <= THRESHOLD_YELLOW_MAX:
|
||||
return STATUS_YELLOW
|
||||
else:
|
||||
return STATUS_RED
|
||||
|
||||
|
||||
def should_send_email(line_count: int) -> bool:
|
||||
"""
|
||||
Determine if email notification should be sent
|
||||
|
||||
Args:
|
||||
line_count: Number of lines in file
|
||||
|
||||
Returns:
|
||||
True if line count exceeds email trigger threshold
|
||||
"""
|
||||
return line_count >= THRESHOLD_EMAIL_TRIGGER
|
||||
|
||||
|
||||
def get_health_info(file_path: Path | str) -> dict:
|
||||
"""
|
||||
Get complete health information for a file
|
||||
|
||||
Args:
|
||||
file_path: Path to file to analyze
|
||||
|
||||
Returns:
|
||||
Dict with line_count, status, needs_email
|
||||
"""
|
||||
json_handler.log_operation("get_health_info", {"file_path": str(file_path)})
|
||||
|
||||
line_count = count_file_lines(file_path)
|
||||
|
||||
return {
|
||||
"line_count": line_count,
|
||||
"status": get_status_from_count(line_count),
|
||||
"needs_email": should_send_email(line_count),
|
||||
"file_path": str(file_path)
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
console.print("\n" + "="*70)
|
||||
console.print("MEMORY HEALTH HANDLER")
|
||||
console.print("="*70)
|
||||
console.print("\nFunctions provided:")
|
||||
console.print(" - count_file_lines(file_path) -> int")
|
||||
console.print(" - get_status_from_count(line_count) -> str")
|
||||
console.print(" - should_send_email(line_count) -> bool")
|
||||
console.print(" - get_health_info(file_path) -> dict")
|
||||
console.print("\nThresholds:")
|
||||
console.print(f" Green: 0-{THRESHOLD_GREEN_MAX} lines")
|
||||
console.print(f" Yellow: {THRESHOLD_YELLOW_MIN}-{THRESHOLD_YELLOW_MAX} lines")
|
||||
console.print(f" Red: {THRESHOLD_RED_MIN}+ lines")
|
||||
console.print(f" Email trigger: {THRESHOLD_EMAIL_TRIGGER}+ lines")
|
||||
console.print("\n" + "="*70 + "\n")
|
||||
@@ -1,5 +1 @@
|
||||
"""Registry Handlers - Branch registry operations for AI_Mail"""
|
||||
|
||||
from .load import load_registry
|
||||
|
||||
__all__ = ['load_registry']
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: load.py
|
||||
# Description: Registry Loading Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2025-11-15
|
||||
# Modified: 2025-11-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Registry Loading Handler - Loads registry files from disk.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def load_registry(registry_file: Path) -> Dict:
|
||||
"""
|
||||
Load branch registry from file.
|
||||
|
||||
Args:
|
||||
registry_file: Path to the registry JSON file
|
||||
|
||||
Returns:
|
||||
Registry dictionary with structure:
|
||||
{
|
||||
"last_updated": str,
|
||||
"active_branches": dict,
|
||||
"statistics": {
|
||||
"total_branches": int,
|
||||
"green_status": int,
|
||||
"yellow_status": int,
|
||||
"red_status": int
|
||||
}
|
||||
}
|
||||
"""
|
||||
json_handler.log_operation("load_registry", {"registry_file": str(registry_file)})
|
||||
|
||||
if not registry_file.exists():
|
||||
return {
|
||||
"last_updated": "",
|
||||
"active_branches": {},
|
||||
"statistics": {
|
||||
"total_branches": 0,
|
||||
"green_status": 0,
|
||||
"yellow_status": 0,
|
||||
"red_status": 0
|
||||
}
|
||||
}
|
||||
|
||||
# Direct file read for registry (non-standard JSON location)
|
||||
with open(registry_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
@@ -1,272 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: update.py
|
||||
# Description: Registry Update Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2025-11-15
|
||||
# Modified: 2025-11-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Registry Update Handler
|
||||
|
||||
Handles updating branch registry with ping data including:
|
||||
- Updating registry with branch status
|
||||
- Recording ping timestamps
|
||||
- Maintaining statistics (green/yellow/red counts)
|
||||
|
||||
Handler Independence:
|
||||
- No module imports from ai_mail
|
||||
- Only uses Prax logger and standard library
|
||||
- Fully transportable and self-contained
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# Constants
|
||||
MODULE_NAME = "registry.update"
|
||||
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
AI_MAIL_JSON = _AI_MAIL_ROOT / ".ai_mail.local"
|
||||
REGISTRY_PATH = AI_MAIL_JSON / "local_memory_monitor_registry.json"
|
||||
THRESHOLDS = {
|
||||
"green": (0, 400),
|
||||
"yellow": (401, 550),
|
||||
"red": (551, float('inf'))
|
||||
}
|
||||
|
||||
|
||||
def ping_registry(
|
||||
branch_name: str,
|
||||
branch_path: Path,
|
||||
local_status: Dict,
|
||||
obs_status: Dict
|
||||
) -> bool:
|
||||
"""
|
||||
Update registry with branch status.
|
||||
|
||||
Args:
|
||||
branch_name: Name of branch (e.g., "FLOW", "AIPASS.admin")
|
||||
branch_path: Full path to branch directory
|
||||
local_status: Dict with {"line_count": int, "status": str}
|
||||
obs_status: Dict with {"line_count": int, "status": str}
|
||||
|
||||
Returns:
|
||||
True if registry updated successfully, False otherwise
|
||||
"""
|
||||
json_handler.log_operation("ping_registry", {"branch_name": branch_name, "branch_path": str(branch_path)})
|
||||
|
||||
try:
|
||||
# Ensure registry directory exists
|
||||
REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load or create registry
|
||||
if REGISTRY_PATH.exists():
|
||||
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
else:
|
||||
registry = _create_empty_registry()
|
||||
|
||||
# Update branch entry
|
||||
registry["active_branches"][str(branch_path)] = {
|
||||
"branch_name": branch_name,
|
||||
"last_ping": datetime.now().isoformat(),
|
||||
"local_md": local_status,
|
||||
"observations_md": obs_status
|
||||
}
|
||||
|
||||
# Update statistics
|
||||
registry["last_updated"] = datetime.now().isoformat()
|
||||
registry["statistics"] = _calculate_statistics(registry)
|
||||
|
||||
# Save registry
|
||||
with open(REGISTRY_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(registry, f, indent=2)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[registry] ping_registry failed for %s: %s", branch_name, e)
|
||||
return False
|
||||
|
||||
|
||||
def _create_empty_registry() -> Dict:
|
||||
"""
|
||||
Create empty registry structure.
|
||||
|
||||
Returns:
|
||||
Empty registry dict
|
||||
"""
|
||||
return {
|
||||
"last_updated": "",
|
||||
"active_branches": {},
|
||||
"statistics": {
|
||||
"total_branches": 0,
|
||||
"green_status": 0,
|
||||
"yellow_status": 0,
|
||||
"red_status": 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _calculate_statistics(registry: Dict) -> Dict:
|
||||
"""
|
||||
Calculate statistics from registry data.
|
||||
|
||||
Args:
|
||||
registry: Full registry dict
|
||||
|
||||
Returns:
|
||||
Statistics dict with counts
|
||||
"""
|
||||
green, yellow, red = 0, 0, 0
|
||||
|
||||
for branch_data in registry["active_branches"].values():
|
||||
for file_type in ["local_md", "observations_md"]:
|
||||
status = branch_data.get(file_type, {}).get("status", "")
|
||||
if status == "green":
|
||||
green += 1
|
||||
elif status == "yellow":
|
||||
yellow += 1
|
||||
elif status == "red":
|
||||
red += 1
|
||||
|
||||
return {
|
||||
"total_branches": len(registry["active_branches"]),
|
||||
"green_status": green,
|
||||
"yellow_status": yellow,
|
||||
"red_status": red
|
||||
}
|
||||
|
||||
|
||||
def get_status_from_count(line_count: int) -> str:
|
||||
"""
|
||||
Determine status based on line count.
|
||||
|
||||
Args:
|
||||
line_count: Number of lines in file
|
||||
|
||||
Returns:
|
||||
Status code: "green", "yellow", or "red"
|
||||
"""
|
||||
if THRESHOLDS["green"][0] <= line_count <= THRESHOLDS["green"][1]:
|
||||
return "green"
|
||||
elif THRESHOLDS["yellow"][0] <= line_count <= THRESHOLDS["yellow"][1]:
|
||||
return "yellow"
|
||||
else: # red threshold
|
||||
return "red"
|
||||
|
||||
|
||||
def count_file_lines(file_path: Path) -> int:
|
||||
"""
|
||||
Count total lines in file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to count
|
||||
|
||||
Returns:
|
||||
Number of lines in file, 0 if file doesn't exist
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return len(f.readlines())
|
||||
except Exception as e:
|
||||
logger.warning("[registry] count_file_lines failed for %s: %s", file_path, e)
|
||||
return 0
|
||||
|
||||
|
||||
def update_json_memory_health(
|
||||
file_path: Path,
|
||||
line_count: int,
|
||||
status_code: str
|
||||
) -> bool:
|
||||
"""
|
||||
Update memory_health in JSON file metadata.
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
line_count: Current line count
|
||||
status_code: Status ("green", "yellow", "red")
|
||||
|
||||
Returns:
|
||||
True if updated successfully, False otherwise
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Update memory health in metadata
|
||||
if "metadata" in data and "memory_health" in data["metadata"]:
|
||||
data["metadata"]["memory_health"]["current_lines"] = line_count
|
||||
data["metadata"]["memory_health"]["status"] = status_code
|
||||
|
||||
# Save updated file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[registry] update_json_memory_health failed for %s: %s", file_path, e)
|
||||
return False
|
||||
|
||||
|
||||
def get_branch_context() -> Tuple[str, Path]:
|
||||
"""
|
||||
Determine current branch name and directory.
|
||||
|
||||
Returns:
|
||||
Tuple of (branch_name, branch_path)
|
||||
"""
|
||||
cwd = Path.cwd()
|
||||
|
||||
# Special case: root directory
|
||||
if cwd == Path("/"):
|
||||
return "AIPASS.admin", cwd
|
||||
|
||||
# Extract branch name from last directory in path
|
||||
branch_folder = cwd.name.replace("-", "_")
|
||||
branch_name = branch_folder.upper()
|
||||
|
||||
return branch_name, cwd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
console.print("\n" + "="*70)
|
||||
console.print("AI_MAIL HANDLER: registry/update.py")
|
||||
console.print("="*70)
|
||||
console.print("\nRegistry Update Handler")
|
||||
console.print()
|
||||
console.print("FUNCTIONS PROVIDED:")
|
||||
console.print(" - ping_registry(branch_name, branch_path, local_status, obs_status) -> bool")
|
||||
console.print(" - get_status_from_count(line_count) -> str")
|
||||
console.print(" - count_file_lines(file_path) -> int")
|
||||
console.print(" - update_json_memory_health(file_path, line_count, status_code) -> bool")
|
||||
console.print(" - get_branch_context() -> Tuple[str, Path]")
|
||||
console.print()
|
||||
console.print("THRESHOLDS:")
|
||||
console.print(f" Green: {THRESHOLDS['green'][0]} - {THRESHOLDS['green'][1]} lines")
|
||||
console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines")
|
||||
console.print(f" Red: {THRESHOLDS['red'][0]}+ lines")
|
||||
console.print()
|
||||
console.print("TESTING:")
|
||||
|
||||
branch_name, branch_path = get_branch_context()
|
||||
console.print(f"\nCurrent branch: {branch_name}")
|
||||
console.print(f"Current path: {branch_path}")
|
||||
|
||||
console.print("\n" + "="*70 + "\n")
|
||||
@@ -1,232 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: branch_ping.py
|
||||
# Description: Branch Ping Orchestration Module
|
||||
# Version: 1.0.0
|
||||
# Created: 2025-11-15
|
||||
# Modified: 2025-11-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Branch Ping Orchestration Module
|
||||
|
||||
Orchestrates branch memory health monitoring - delegates all logic to handlers.
|
||||
Commands: ping, status, registry, thresholds
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for formatting
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from rich.panel import Panel
|
||||
|
||||
# Import handlers
|
||||
from aipass.ai_mail.apps.handlers.monitoring.memory import count_file_lines, get_status_from_count, get_health_info
|
||||
from aipass.ai_mail.apps.handlers.registry.update import (
|
||||
ping_registry,
|
||||
get_branch_context,
|
||||
update_json_memory_health
|
||||
)
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
MODULE_NAME = "branch_ping"
|
||||
THRESHOLDS = {"green": (0, 400), "yellow": (401, 550), "red": (551, float('inf'))}
|
||||
|
||||
def handle_ping(verbose: bool = False) -> bool:
|
||||
"""Execute ping command - orchestrate health check"""
|
||||
try:
|
||||
branch_name, cwd = get_branch_context()
|
||||
local_file = cwd / ".trinity" / "local.json"
|
||||
obs_file = cwd / ".trinity" / "observations.json"
|
||||
|
||||
local_count = count_file_lines(local_file)
|
||||
obs_count = count_file_lines(obs_file)
|
||||
local_status_code = get_status_from_count(local_count)
|
||||
obs_status_code = get_status_from_count(obs_count)
|
||||
|
||||
update_json_memory_health(local_file, local_count, local_status_code)
|
||||
update_json_memory_health(obs_file, obs_count, obs_status_code)
|
||||
|
||||
local_status = {"line_count": local_count, "status": local_status_code}
|
||||
obs_status = {"line_count": obs_count, "status": obs_status_code}
|
||||
ping_registry(branch_name, cwd, local_status, obs_status)
|
||||
|
||||
json_handler.log_operation("ping_executed", {"branch": branch_name, "local_count": local_count, "obs_count": obs_count})
|
||||
|
||||
if verbose:
|
||||
console.print(f"Ping successful for {branch_name}")
|
||||
console.print(f" .trinity/local.json: {local_count} lines ({local_status_code})")
|
||||
console.print(f" .trinity/observations.json: {obs_count} lines ({obs_status_code})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ping failed: {e}")
|
||||
if verbose:
|
||||
error(f"Ping failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_status() -> bool:
|
||||
"""Show current memory health status"""
|
||||
try:
|
||||
branch_name, cwd = get_branch_context()
|
||||
local_file = cwd / ".trinity" / "local.json"
|
||||
obs_file = cwd / ".trinity" / "observations.json"
|
||||
|
||||
local_health = get_health_info(local_file)
|
||||
obs_health = get_health_info(obs_file)
|
||||
|
||||
console.print(f"\nBranch: {branch_name}\nDirectory: {cwd}")
|
||||
console.print(f"\nMemory Health Status:")
|
||||
console.print(f" .trinity/local.json: {local_health['line_count']} lines ({local_health['status']})")
|
||||
console.print(f" .trinity/observations.json: {obs_health['line_count']} lines ({obs_health['status']})\n")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Status check failed: {e}")
|
||||
error(f"Error getting status: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_registry() -> bool:
|
||||
"""View registry contents"""
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.registry.update import REGISTRY_PATH
|
||||
from aipass.ai_mail.apps.handlers.registry.load import load_registry
|
||||
|
||||
if not REGISTRY_PATH.exists():
|
||||
console.print("Registry not yet created")
|
||||
return True
|
||||
|
||||
registry = load_registry(REGISTRY_PATH)
|
||||
|
||||
console.print("\nMemory Health Registry")
|
||||
console.print(f"Last Updated: {registry.get('last_updated', 'N/A')}\n")
|
||||
stats = registry.get('statistics', {})
|
||||
console.print(f"Statistics:")
|
||||
console.print(f" Total Branches: {stats.get('total_branches', 0)}")
|
||||
console.print(f" Green: {stats.get('green_status', 0)}, Yellow: {stats.get('yellow_status', 0)}, Red: {stats.get('red_status', 0)}\n")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Registry read failed: {e}")
|
||||
error(f"Error reading registry: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_thresholds() -> bool:
|
||||
"""Show compression thresholds"""
|
||||
console.print("\nMemory Compression Thresholds:")
|
||||
console.print(f" Green: 0 - {THRESHOLDS['green'][1]} lines")
|
||||
console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines")
|
||||
console.print(f" Red: {THRESHOLDS['red'][0]}+ lines (compression required)")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help output using argparse"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Branch Ping - Memory Health Monitoring Module',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
ping - Execute health check and update registry
|
||||
status - Show current memory status for this branch
|
||||
registry - View registry contents and statistics
|
||||
thresholds - Show compression thresholds
|
||||
|
||||
USAGE:
|
||||
drone @ai_mail branch_ping <command>
|
||||
drone @ai_mail branch_ping --help
|
||||
|
||||
EXAMPLES:
|
||||
# Check memory health and update registry
|
||||
drone @ai_mail branch_ping ping
|
||||
|
||||
# View current status
|
||||
drone @ai_mail branch_ping status
|
||||
|
||||
# View registry
|
||||
drone @ai_mail branch_ping registry
|
||||
|
||||
# Show thresholds
|
||||
drone @ai_mail branch_ping thresholds
|
||||
"""
|
||||
)
|
||||
console.print(parser.format_help())
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle incoming command - main orchestration entry point"""
|
||||
# Check if this module handles this command
|
||||
if command not in ["ping", "status", "registry", "thresholds"]:
|
||||
return False
|
||||
|
||||
# Handle help flag
|
||||
if args and args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if command == "ping":
|
||||
return handle_ping("--verbose" in args or "-v" in args)
|
||||
elif command == "status":
|
||||
return handle_status()
|
||||
elif command == "registry":
|
||||
return handle_registry()
|
||||
elif command == "thresholds":
|
||||
return handle_thresholds()
|
||||
return False
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("branch_ping Module")
|
||||
console.print("Orchestrates branch memory health monitoring: ping, status, registry, and thresholds.")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/monitoring/")
|
||||
console.print(" - memory.py (count_file_lines — count lines in a memory file)")
|
||||
console.print(" - memory.py (get_status_from_count — derive health status from line count)")
|
||||
console.print(" handlers/registry/")
|
||||
console.print(" - update.py (ping_registry — update memory health registry for a branch)")
|
||||
console.print(" - update.py (get_branch_context — resolve current branch name and directory)")
|
||||
console.print(" - update.py (update_json_memory_health — write health metadata into memory file)")
|
||||
console.print(" - load.py (load_registry — load the memory health registry file)")
|
||||
console.print(" handlers/json_utils/")
|
||||
console.print(" - json_handler.py (log_operation — log structured operation to JSON)")
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Handle --help flag
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']:
|
||||
console.print()
|
||||
console.print(Panel("[bold cyan]BRANCH PING - Memory Health Monitoring[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds, --help")
|
||||
console.print()
|
||||
console.print("[bold]USAGE:[/bold]")
|
||||
console.print(" drone @ai_mail branch_ping <command>")
|
||||
console.print(" drone @ai_mail branch_ping --help")
|
||||
console.print()
|
||||
console.print("[bold]COMMANDS:[/bold]")
|
||||
console.print(" [cyan]ping[/cyan] - Execute health check")
|
||||
console.print(" [cyan]status[/cyan] - Show current memory status")
|
||||
console.print(" [cyan]registry[/cyan] - View registry contents")
|
||||
console.print(" [cyan]thresholds[/cyan] - Show compression thresholds")
|
||||
console.print()
|
||||
sys.exit(0)
|
||||
|
||||
console.print()
|
||||
console.print(Panel("[bold cyan]BRANCH PING ORCHESTRATION MODULE[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds")
|
||||
console.print("[dim]Usage: drone @ai_mail branch_ping [command][/dim]")
|
||||
console.print()
|
||||
@@ -349,11 +349,10 @@ def handle_close(args: List[str]) -> bool:
|
||||
success, message, count = mark_all_read_and_archive(branch_path)
|
||||
if success:
|
||||
console.print(f"[green]{message}[/green]")
|
||||
json_handler.log_operation("email_closed_all", {"count": count})
|
||||
else:
|
||||
error(message)
|
||||
if success:
|
||||
json_handler.log_operation("email_closed_all", {"count": count})
|
||||
return success
|
||||
return True
|
||||
|
||||
results, closed, failed = batch_close(branch_path, args, mark_as_closed_and_archive)
|
||||
for msg_id, success, message in results:
|
||||
@@ -373,7 +372,7 @@ def handle_close(args: List[str]) -> bool:
|
||||
batch_close_post_ops(branch_path, push_dashboard_update, update_central,
|
||||
run_purge)
|
||||
console.print(f"\nClosed {closed}, failed {failed}")
|
||||
return failed == 0
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[email] Close failed: {e}")
|
||||
error(f"Error: {e}")
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_branch_ping.py
|
||||
# Description: Tests for branch ping orchestration module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-03
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""Tests for branch_ping module -- command routing and orchestration."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import aipass.ai_mail.apps.modules.branch_ping as mod
|
||||
|
||||
|
||||
# --- Fixtures --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _suppress_log_operation(monkeypatch):
|
||||
"""Prevent json_handler.log_operation from touching real files."""
|
||||
monkeypatch.setattr(mod, "json_handler", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _suppress_logger(monkeypatch):
|
||||
"""Suppress logger output during tests."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_console(monkeypatch):
|
||||
"""Mock console and error to prevent real rich output."""
|
||||
mock_console = MagicMock()
|
||||
monkeypatch.setattr(mod, "console", mock_console)
|
||||
monkeypatch.setattr(mod, "error", MagicMock())
|
||||
return mock_console
|
||||
|
||||
|
||||
# --- handle_command tests ---------------------------------------------
|
||||
|
||||
|
||||
def test_handle_command_returns_false_for_unknown_command():
|
||||
"""Returns False for commands not handled by this module."""
|
||||
assert mod.handle_command("unknown_cmd", ["run"]) is False
|
||||
|
||||
|
||||
def test_handle_command_returns_false_for_empty_unknown():
|
||||
"""Returns False for another unrecognized command."""
|
||||
assert mod.handle_command("deploy", []) is False
|
||||
|
||||
|
||||
def test_handle_command_routes_ping(monkeypatch):
|
||||
"""Routes 'ping' command to handle_ping."""
|
||||
mock_ping = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(mod, "handle_ping", mock_ping)
|
||||
|
||||
result = mod.handle_command("ping", ["run"])
|
||||
assert result is True
|
||||
mock_ping.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_command_routes_ping_with_verbose(monkeypatch):
|
||||
"""Routes 'ping' with --verbose flag."""
|
||||
mock_ping = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(mod, "handle_ping", mock_ping)
|
||||
|
||||
mod.handle_command("ping", ["--verbose"])
|
||||
mock_ping.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_handle_command_routes_status(monkeypatch):
|
||||
"""Routes 'status' command to handle_status."""
|
||||
mock_status = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(mod, "handle_status", mock_status)
|
||||
|
||||
result = mod.handle_command("status", ["run"])
|
||||
assert result is True
|
||||
mock_status.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_command_routes_registry(monkeypatch):
|
||||
"""Routes 'registry' command to handle_registry."""
|
||||
mock_registry = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(mod, "handle_registry", mock_registry)
|
||||
|
||||
result = mod.handle_command("registry", ["run"])
|
||||
assert result is True
|
||||
mock_registry.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_command_routes_thresholds():
|
||||
"""Routes 'thresholds' command to handle_thresholds."""
|
||||
result = mod.handle_command("thresholds", ["run"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_help_flag(monkeypatch):
|
||||
"""--help flag calls print_help and returns True."""
|
||||
mock_help = MagicMock()
|
||||
monkeypatch.setattr(mod, "print_help", mock_help)
|
||||
|
||||
result = mod.handle_command("ping", ["--help"])
|
||||
assert result is True
|
||||
mock_help.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_command_h_flag(monkeypatch):
|
||||
"""-h flag calls print_help and returns True."""
|
||||
mock_help = MagicMock()
|
||||
monkeypatch.setattr(mod, "print_help", mock_help)
|
||||
|
||||
result = mod.handle_command("status", ["-h"])
|
||||
assert result is True
|
||||
mock_help.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_command_help_word(monkeypatch):
|
||||
"""'help' as first arg calls print_help and returns True."""
|
||||
mock_help = MagicMock()
|
||||
monkeypatch.setattr(mod, "print_help", mock_help)
|
||||
|
||||
result = mod.handle_command("registry", ["help"])
|
||||
assert result is True
|
||||
mock_help.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_command_no_args_calls_introspection(monkeypatch):
|
||||
"""No args calls print_introspection and returns True."""
|
||||
mock_introspect = MagicMock()
|
||||
monkeypatch.setattr(mod, "print_introspection", mock_introspect)
|
||||
|
||||
result = mod.handle_command("ping", [])
|
||||
assert result is True
|
||||
mock_introspect.assert_called_once()
|
||||
|
||||
|
||||
# --- handle_thresholds tests ------------------------------------------
|
||||
|
||||
|
||||
def test_handle_thresholds_returns_true():
|
||||
"""handle_thresholds returns True."""
|
||||
assert mod.handle_thresholds() is True
|
||||
|
||||
|
||||
def test_handle_thresholds_prints_values(_mock_console):
|
||||
"""handle_thresholds prints threshold range values."""
|
||||
mod.handle_thresholds()
|
||||
|
||||
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
|
||||
assert "400" in printed
|
||||
assert "401" in printed
|
||||
assert "550" in printed
|
||||
assert "551" in printed
|
||||
|
||||
|
||||
# --- handle_ping tests ------------------------------------------------
|
||||
|
||||
|
||||
def test_handle_ping_returns_true_on_success(monkeypatch):
|
||||
"""handle_ping returns True when all handlers succeed."""
|
||||
monkeypatch.setattr(mod, "get_branch_context", lambda: ("test-branch", Path("/tmp/test")))
|
||||
monkeypatch.setattr(mod, "count_file_lines", lambda f: 100)
|
||||
monkeypatch.setattr(mod, "get_status_from_count", lambda c: "green")
|
||||
monkeypatch.setattr(mod, "update_json_memory_health", MagicMock())
|
||||
monkeypatch.setattr(mod, "ping_registry", MagicMock())
|
||||
|
||||
assert mod.handle_ping(verbose=False) is True
|
||||
|
||||
|
||||
def test_handle_ping_verbose_prints_output(monkeypatch, _mock_console):
|
||||
"""handle_ping with verbose=True prints status info."""
|
||||
monkeypatch.setattr(mod, "get_branch_context", lambda: ("my-branch", Path("/tmp/test")))
|
||||
monkeypatch.setattr(mod, "count_file_lines", lambda f: 250)
|
||||
monkeypatch.setattr(mod, "get_status_from_count", lambda c: "yellow")
|
||||
monkeypatch.setattr(mod, "update_json_memory_health", MagicMock())
|
||||
monkeypatch.setattr(mod, "ping_registry", MagicMock())
|
||||
|
||||
mod.handle_ping(verbose=True)
|
||||
|
||||
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
|
||||
assert "my-branch" in printed
|
||||
assert "250" in printed
|
||||
assert "yellow" in printed
|
||||
|
||||
|
||||
def test_handle_ping_returns_false_on_exception(monkeypatch):
|
||||
"""handle_ping returns False when get_branch_context raises."""
|
||||
def raise_error():
|
||||
raise RuntimeError("git not found")
|
||||
|
||||
monkeypatch.setattr(mod, "get_branch_context", raise_error)
|
||||
|
||||
assert mod.handle_ping(verbose=False) is False
|
||||
|
||||
|
||||
def test_handle_ping_calls_ping_registry(monkeypatch):
|
||||
"""handle_ping calls ping_registry with correct arguments."""
|
||||
mock_ping_reg = MagicMock()
|
||||
monkeypatch.setattr(mod, "get_branch_context", lambda: ("feat-x", Path("/tmp/feat")))
|
||||
monkeypatch.setattr(mod, "count_file_lines", lambda f: 50)
|
||||
monkeypatch.setattr(mod, "get_status_from_count", lambda c: "green")
|
||||
monkeypatch.setattr(mod, "update_json_memory_health", MagicMock())
|
||||
monkeypatch.setattr(mod, "ping_registry", mock_ping_reg)
|
||||
|
||||
mod.handle_ping(verbose=False)
|
||||
|
||||
mock_ping_reg.assert_called_once_with(
|
||||
"feat-x",
|
||||
Path("/tmp/feat"),
|
||||
{"line_count": 50, "status": "green"},
|
||||
{"line_count": 50, "status": "green"},
|
||||
)
|
||||
|
||||
|
||||
# --- handle_status tests ---------------------------------------------
|
||||
|
||||
|
||||
def test_handle_status_returns_true_on_success(monkeypatch):
|
||||
"""handle_status returns True when handlers succeed."""
|
||||
monkeypatch.setattr(mod, "get_branch_context", lambda: ("main", Path("/tmp/main")))
|
||||
monkeypatch.setattr(mod, "get_health_info", lambda f: {"line_count": 100, "status": "green"})
|
||||
|
||||
assert mod.handle_status() is True
|
||||
|
||||
|
||||
def test_handle_status_returns_false_on_exception(monkeypatch):
|
||||
"""handle_status returns False when an exception is raised."""
|
||||
def raise_error():
|
||||
raise RuntimeError("file not found")
|
||||
|
||||
monkeypatch.setattr(mod, "get_branch_context", raise_error)
|
||||
|
||||
assert mod.handle_status() is False
|
||||
|
||||
|
||||
# --- print_introspection tests ----------------------------------------
|
||||
|
||||
|
||||
def test_print_introspection_prints_module_info(_mock_console):
|
||||
"""print_introspection prints module name and handler info."""
|
||||
mod.print_introspection()
|
||||
|
||||
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
|
||||
assert "branch_ping" in printed
|
||||
assert "handlers/monitoring/" in printed
|
||||
assert "handlers/registry/" in printed
|
||||
|
||||
|
||||
# --- print_help tests -------------------------------------------------
|
||||
|
||||
|
||||
def test_print_help_prints_command_list(_mock_console):
|
||||
"""print_help prints available commands."""
|
||||
mod.print_help()
|
||||
|
||||
printed = " ".join(str(c) for c in _mock_console.print.call_args_list)
|
||||
assert "ping" in printed
|
||||
assert "status" in printed
|
||||
assert "registry" in printed
|
||||
assert "thresholds" in printed
|
||||
@@ -1,196 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_monitoring_memory.py
|
||||
# Description: Tests for memory health handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-24
|
||||
# Modified: 2026-03-24
|
||||
# =============================================
|
||||
|
||||
"""Tests for memory health handler -- line counting and health status."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from aipass.ai_mail.apps.handlers.monitoring.memory import (
|
||||
count_file_lines,
|
||||
get_status_from_count,
|
||||
should_send_email,
|
||||
get_health_info,
|
||||
THRESHOLD_GREEN_MAX,
|
||||
THRESHOLD_YELLOW_MIN,
|
||||
THRESHOLD_YELLOW_MAX,
|
||||
THRESHOLD_EMAIL_TRIGGER,
|
||||
STATUS_GREEN,
|
||||
STATUS_YELLOW,
|
||||
STATUS_RED,
|
||||
)
|
||||
|
||||
|
||||
# ---- Fixtures ------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _silence_json_handler():
|
||||
"""Prevent log_operation from writing real JSON files during tests."""
|
||||
with patch("aipass.ai_mail.apps.handlers.monitoring.memory.json_handler") as mock_jh:
|
||||
mock_jh.log_operation.return_value = True
|
||||
yield mock_jh
|
||||
|
||||
|
||||
# ---- count_file_lines tests ----------------------------------
|
||||
|
||||
|
||||
def test_count_file_lines_missing(tmp_path):
|
||||
"""Nonexistent file returns 0."""
|
||||
result = count_file_lines(tmp_path / "nonexistent_file.txt")
|
||||
|
||||
assert isinstance(result, int)
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_count_file_lines_empty(tmp_path):
|
||||
"""Empty file returns 0."""
|
||||
empty_file = tmp_path / "empty.txt"
|
||||
empty_file.write_text("", encoding="utf-8")
|
||||
|
||||
result = count_file_lines(empty_file)
|
||||
|
||||
assert isinstance(result, int)
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_count_file_lines_content(tmp_path):
|
||||
"""File with 5 lines returns 5."""
|
||||
file_path = tmp_path / "five_lines.txt"
|
||||
file_path.write_text("line1\nline2\nline3\nline4\nline5\n", encoding="utf-8")
|
||||
|
||||
result = count_file_lines(file_path)
|
||||
|
||||
assert isinstance(result, int)
|
||||
assert result == 5
|
||||
|
||||
|
||||
# ---- get_status_from_count tests -----------------------------
|
||||
|
||||
|
||||
def test_status_green():
|
||||
"""100 lines is well within green threshold -- returns STATUS_GREEN."""
|
||||
assert 100 <= THRESHOLD_GREEN_MAX, "Precondition: 100 must be in green range"
|
||||
|
||||
result = get_status_from_count(100)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert result == STATUS_GREEN
|
||||
|
||||
|
||||
def test_status_yellow():
|
||||
"""450 lines falls in yellow range -- returns STATUS_YELLOW."""
|
||||
assert THRESHOLD_YELLOW_MIN <= 450 <= THRESHOLD_YELLOW_MAX, "Precondition: 450 must be in yellow range"
|
||||
|
||||
result = get_status_from_count(450)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert result == STATUS_YELLOW
|
||||
|
||||
|
||||
def test_status_red():
|
||||
"""600 lines is in red zone -- returns STATUS_RED."""
|
||||
assert 600 > THRESHOLD_YELLOW_MAX, "Precondition: 600 must be above yellow range"
|
||||
|
||||
result = get_status_from_count(600)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert result == STATUS_RED
|
||||
|
||||
|
||||
def test_status_green_at_boundary():
|
||||
"""Exactly THRESHOLD_GREEN_MAX (400) should still be green."""
|
||||
result = get_status_from_count(THRESHOLD_GREEN_MAX)
|
||||
assert result == STATUS_GREEN
|
||||
|
||||
|
||||
def test_status_yellow_at_lower_boundary():
|
||||
"""Exactly THRESHOLD_YELLOW_MIN (401) should be yellow."""
|
||||
result = get_status_from_count(THRESHOLD_YELLOW_MIN)
|
||||
assert result == STATUS_YELLOW
|
||||
|
||||
|
||||
def test_status_yellow_at_upper_boundary():
|
||||
"""Exactly THRESHOLD_YELLOW_MAX (550) should still be yellow."""
|
||||
result = get_status_from_count(THRESHOLD_YELLOW_MAX)
|
||||
assert result == STATUS_YELLOW
|
||||
|
||||
|
||||
def test_status_red_at_boundary():
|
||||
"""One above THRESHOLD_YELLOW_MAX (551) should be red."""
|
||||
result = get_status_from_count(THRESHOLD_YELLOW_MAX + 1)
|
||||
assert result == STATUS_RED
|
||||
|
||||
|
||||
def test_status_zero_lines():
|
||||
"""Zero lines should be green."""
|
||||
result = get_status_from_count(0)
|
||||
assert result == STATUS_GREEN
|
||||
|
||||
|
||||
# ---- should_send_email tests ---------------------------------
|
||||
|
||||
|
||||
def test_should_send_email_below():
|
||||
"""500 is below THRESHOLD_EMAIL_TRIGGER (600) -- no email."""
|
||||
assert 500 < THRESHOLD_EMAIL_TRIGGER, "Precondition: 500 must be below trigger"
|
||||
|
||||
result = should_send_email(500)
|
||||
|
||||
assert isinstance(result, bool)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_should_send_email_one_below_threshold():
|
||||
"""599 is one below THRESHOLD_EMAIL_TRIGGER -- no email."""
|
||||
result = should_send_email(THRESHOLD_EMAIL_TRIGGER - 1)
|
||||
assert isinstance(result, bool)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_should_send_email_at_threshold():
|
||||
"""Exactly at THRESHOLD_EMAIL_TRIGGER -- email should be sent."""
|
||||
result = should_send_email(THRESHOLD_EMAIL_TRIGGER)
|
||||
assert isinstance(result, bool)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_should_send_email_above_threshold():
|
||||
"""Well above THRESHOLD_EMAIL_TRIGGER -- email should be sent."""
|
||||
result = should_send_email(THRESHOLD_EMAIL_TRIGGER + 100)
|
||||
assert isinstance(result, bool)
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---- get_health_info tests -----------------------------------
|
||||
|
||||
|
||||
def test_get_health_info(tmp_path):
|
||||
"""10-line file returns correct health dict."""
|
||||
file_path = tmp_path / "ten_lines.txt"
|
||||
lines = "\n".join(f"line {i}" for i in range(1, 11)) + "\n"
|
||||
file_path.write_text(lines, encoding="utf-8")
|
||||
|
||||
result = get_health_info(file_path)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
# Verify exactly these keys exist -- no more, no fewer
|
||||
assert set(result.keys()) == {"line_count", "status", "needs_email", "file_path"}
|
||||
|
||||
# Verify exact values and types
|
||||
assert result["line_count"] == 10
|
||||
assert isinstance(result["line_count"], int)
|
||||
|
||||
assert result["status"] == STATUS_GREEN
|
||||
assert isinstance(result["status"], str)
|
||||
|
||||
assert result["needs_email"] is False
|
||||
assert isinstance(result["needs_email"], bool)
|
||||
|
||||
assert result["file_path"] == str(file_path)
|
||||
@@ -60,9 +60,9 @@ drone @flow list open # Active plans
|
||||
drone systems # All branches
|
||||
```
|
||||
|
||||
## Branches (15)
|
||||
## Branches (10 core)
|
||||
|
||||
drone, seedgo, prax, cli, ai_mail, flow, spawn, trigger, api, backup, daemon, memory, commons (`src/commons/`), skills (`src/skills/`), devpulse (you — no apps/, coordinates via dispatch + agents)
|
||||
drone, seedgo, prax, cli, ai_mail, flow, spawn, trigger, memory, devpulse (you — no apps/, coordinates via dispatch + agents)
|
||||
|
||||
## Your Project
|
||||
|
||||
|
||||
@@ -146,7 +146,6 @@ Commands in the interactive tuple bypass capture and inherit the terminal direct
|
||||
| Branch | Reason |
|
||||
|----------|-----------------------------------------------|
|
||||
| `cli` | User-facing CLI with Rich formatted output |
|
||||
| `backup` | Heavy-IO branch (file scanning, Google APIs) |
|
||||
|
||||
To add: edit `interactive_commands` or `interactive_branches` in `_handle_target()` in `apps/drone.py`.
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ def _handle_custom_command(args: list[str]) -> int:
|
||||
|
||||
# Interactive detection -- same logic as _handle_target
|
||||
interactive_commands = ("monitor", "audit")
|
||||
interactive_branches = ("cli", "backup")
|
||||
interactive_branches = ("cli",)
|
||||
interactive = command in interactive_commands or module_name in interactive_branches
|
||||
|
||||
try:
|
||||
@@ -322,7 +322,7 @@ def _handle_target(args: List[str]) -> int:
|
||||
# Per-command: specific commands that need live terminal (progress bars, TUI).
|
||||
# Per-branch: all commands from that branch get interactive mode (Rich CLI).
|
||||
interactive_commands = ("monitor", "audit")
|
||||
interactive_branches = ("cli", "backup")
|
||||
interactive_branches = ("cli",)
|
||||
first_cmd = rest[0] if rest and rest[0] != "--help" else None
|
||||
needs_interactive = (
|
||||
first_cmd in interactive_commands or module_name in interactive_branches
|
||||
|
||||
@@ -52,7 +52,8 @@ def handle_command(command: Optional[str] = None, args: Optional[List[str]] = No
|
||||
return False
|
||||
try:
|
||||
path = resolve_branch(args[0])
|
||||
except BranchNotFoundError:
|
||||
except BranchNotFoundError as exc:
|
||||
logger.warning("resolver resolve: branch '%s' not found: %s", args[0], exc)
|
||||
err_console.print(f"resolver: branch '{args[0]}' not found")
|
||||
return False
|
||||
console.print(f"{args[0]} -> {path}")
|
||||
@@ -69,7 +70,8 @@ def handle_command(command: Optional[str] = None, args: Optional[List[str]] = No
|
||||
return False
|
||||
try:
|
||||
info = get_branch_info(args[0])
|
||||
except BranchNotFoundError:
|
||||
except BranchNotFoundError as exc:
|
||||
logger.warning("resolver info: branch '%s' not found: %s", args[0], exc)
|
||||
err_console.print(f"resolver: branch '{args[0]}' not found")
|
||||
return False
|
||||
console.print(f"Branch info: {info}")
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
### What I Do
|
||||
- Create numbered plans from type-specific templates via `templates/` plugins
|
||||
- Unified create/close/list commands for all plan types (FPLAN, DPLAN, RPLAN, TDPLAN, ...)
|
||||
- Close plans with foreground archival to `backup/processed_plans/`
|
||||
- Close plans with foreground archival to `.backup/processed_plans/`
|
||||
- Vector intake on close via `drone @memory process-plans` with chroma verification
|
||||
- List and filter plans across branches and plan types
|
||||
- Restore plans from backups
|
||||
@@ -114,7 +114,7 @@ Plans follow the convention `{PREFIX}-{NNNN}_topic_slug_YYYY-MM-DD.md` where NNN
|
||||
On `drone @flow close`:
|
||||
1. Template check (fast-delete empty templates)
|
||||
2. Mark as closed in registry
|
||||
3. Archive to `backup/processed_plans/` (foreground, sets processed/cleanup flags atomically)
|
||||
3. Archive to `.backup/processed_plans/` (foreground, sets processed/cleanup flags atomically)
|
||||
4. Vector intake: `drone @memory process-plans` + `is_plan_vectorized()` verification
|
||||
5. Dashboard updates (local + central + branch)
|
||||
6. Append to `CLOSED_PLANS.local.json`
|
||||
|
||||
@@ -52,7 +52,7 @@ def _find_repo_root() -> Path:
|
||||
|
||||
_REPO_ROOT = _find_repo_root()
|
||||
MEMORY_PATH = _REPO_ROOT / "src" / "aipass" / "memory"
|
||||
PROCESSED_PLANS_DIR = _PKG_ROOT / "backup" / "processed_plans"
|
||||
PROCESSED_PLANS_DIR = _REPO_ROOT / ".backup" / "processed_plans"
|
||||
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
||||
|
||||
# =============================================
|
||||
|
||||
@@ -33,7 +33,16 @@ from aipass.flow.apps.handlers.json import json_handler
|
||||
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
PROCESSED_PLANS_DIR = _PKG_ROOT / "backup" / "processed_plans"
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up to find the repo root (contains AIPASS_REGISTRY.json)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
|
||||
PROCESSED_PLANS_DIR = _find_repo_root() / ".backup" / "processed_plans"
|
||||
|
||||
MODULE_NAME = "restore_plan"
|
||||
|
||||
|
||||
@@ -179,6 +179,59 @@ def load_registry() -> Dict[str, Any]:
|
||||
)
|
||||
save_registry(data)
|
||||
|
||||
# Auto-register: detect new template directories and register them
|
||||
templates_dir = FLOW_ROOT / "templates"
|
||||
if templates_dir.is_dir():
|
||||
registered = set(data["types"].keys())
|
||||
used_prefixes = {
|
||||
entry.get("prefix", "").upper()
|
||||
for entry in data["types"].values()
|
||||
}
|
||||
changed = False
|
||||
for child in sorted(templates_dir.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")) or child.name == "__pycache__":
|
||||
continue
|
||||
if child.name in registered:
|
||||
continue
|
||||
md_files = list(child.glob("*.md"))
|
||||
if not md_files:
|
||||
continue
|
||||
# Derive prefix: first letter of first word + "PLAN"
|
||||
first_word = child.name.split("_")[0]
|
||||
prefix = (first_word[0].upper() + "PLAN") if first_word else "XPLAN"
|
||||
# Avoid collisions — append second letter if needed
|
||||
if prefix in used_prefixes and len(first_word) > 1:
|
||||
prefix = (first_word[:2].upper() + "PLAN")
|
||||
if prefix in used_prefixes:
|
||||
continue # Can't auto-assign — needs manual registration
|
||||
shorthand = prefix.lower()
|
||||
data["types"][child.name] = {
|
||||
"prefix": prefix,
|
||||
"shorthand": shorthand,
|
||||
"created": _today(),
|
||||
"registered_by": "auto",
|
||||
}
|
||||
used_prefixes.add(prefix)
|
||||
registered.add(child.name)
|
||||
changed = True
|
||||
logger.info(
|
||||
"[%s] Auto-registered new type '%s' with prefix %s",
|
||||
MODULE_NAME,
|
||||
child.name,
|
||||
prefix,
|
||||
)
|
||||
# Create empty plan registry for new type
|
||||
plan_reg = FLOW_ROOT / "flow_json" / f"{shorthand}_registry.json"
|
||||
if not plan_reg.exists():
|
||||
try:
|
||||
plan_reg.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(plan_reg, "w", encoding="utf-8") as fh:
|
||||
json.dump({"next_number": 1, "plans": {}, "last_updated": _today()}, fh, indent=2)
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Failed to create plan registry for %s: %s", MODULE_NAME, child.name, exc)
|
||||
if changed:
|
||||
save_registry(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ def handle_command(command: str, args: list) -> bool:
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
if command != "post_close":
|
||||
if command not in ("post", "post_close_runner"):
|
||||
return False
|
||||
|
||||
if not args:
|
||||
|
||||
@@ -204,7 +204,8 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
console.print(f" • Renumbered: {len(result['renumbered'])}")
|
||||
|
||||
if result['healing_performed']:
|
||||
warning(f"Registry healed - {len(result['added']) + len(result['updated']) + len(result['removed'])} changes")
|
||||
change_count = len(result['added']) + len(result['updated']) + len(result['removed'])
|
||||
warning(f"Registry scan found {change_count} mismatch(es) — trigger event handlers not wired, no changes applied")
|
||||
else:
|
||||
console.print(f"\n[dim]No changes needed - registry is healthy[/dim]")
|
||||
|
||||
|
||||
@@ -119,7 +119,10 @@ def _get_memory_file_path(branch: Dict, memory_type: str) -> Path | None:
|
||||
Returns:
|
||||
Path to memory file, or None if not found
|
||||
"""
|
||||
branch_path = Path(branch.get('path', ''))
|
||||
raw_path = branch.get('path', '')
|
||||
if not raw_path:
|
||||
return None
|
||||
branch_path = Path(raw_path)
|
||||
if not branch_path.exists():
|
||||
return None
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ def start_memory_watcher() -> Dict[str, Any]:
|
||||
watched_paths = []
|
||||
for branch_path in branch_paths:
|
||||
try:
|
||||
new_observer.schedule(watcher, str(branch_path), recursive=False)
|
||||
new_observer.schedule(watcher, str(branch_path), recursive=True)
|
||||
watched_paths.append(str(branch_path))
|
||||
except Exception as e:
|
||||
logger.warning(f"[memory_watcher] Failed to schedule watcher for {branch_path}: {e}")
|
||||
|
||||
@@ -61,7 +61,11 @@ def create_rollover_backup(file_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
try:
|
||||
# Create .backup directory in branch root
|
||||
backup_dir = file_path.parent / '.backup'
|
||||
# For .trinity/ files, go up to branch root; otherwise use file's parent
|
||||
if file_path.parent.name == '.trinity':
|
||||
backup_dir = file_path.parent.parent / '.backup'
|
||||
else:
|
||||
backup_dir = file_path.parent / '.backup'
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Backup filename: rollover_backup.json (always overwrites)
|
||||
@@ -98,7 +102,11 @@ def restore_from_backup(file_path: Path) -> Dict[str, Any]:
|
||||
Dict with restore status
|
||||
"""
|
||||
try:
|
||||
backup_dir = file_path.parent / '.backup'
|
||||
# Match backup location from create_rollover_backup
|
||||
if file_path.parent.name == '.trinity':
|
||||
backup_dir = file_path.parent.parent / '.backup'
|
||||
else:
|
||||
backup_dir = file_path.parent / '.backup'
|
||||
backup_name = f'rollover_backup_{file_path.name}'
|
||||
backup_path = backup_dir / backup_name
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from aipass.memory.apps.handlers.json import json_handler
|
||||
# PATH SETUP
|
||||
# =============================================================================
|
||||
|
||||
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent # handlers/templates/differ.py -> memory/
|
||||
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # handlers/templates/differ.py -> apps -> handlers -> templates -> memory/
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS
|
||||
|
||||
@@ -42,7 +42,7 @@ from aipass.memory.apps.handlers.json.memory_files import (
|
||||
# PATH SETUP
|
||||
# =============================================================================
|
||||
|
||||
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent # handlers/templates/pusher.py -> memory/
|
||||
MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # handlers/templates/pusher.py -> apps -> handlers -> templates -> memory/
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
|
||||
@@ -216,62 +216,67 @@ class MonitoringFileHandler(FileSystemEventHandler):
|
||||
return parts[idx + 1].upper()
|
||||
return None
|
||||
|
||||
def _read_codex_cwd(self, file_path) -> Optional[str]:
|
||||
"""Read CWD from the first line (session_meta) of a Codex JSONL file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
first_line = f.readline().strip()
|
||||
if not first_line:
|
||||
return None
|
||||
meta = _json.loads(first_line)
|
||||
if meta.get('type') != 'session_meta':
|
||||
return None
|
||||
return meta.get('payload', {}).get('cwd', '')
|
||||
except (OSError, _json.JSONDecodeError) as e:
|
||||
logger.info(f"[monitor] Could not read Codex session_meta: {e}")
|
||||
return None
|
||||
|
||||
def _get_codex_branch(self, file_path, path_key: str) -> str:
|
||||
"""Get branch for a Codex session, reading session_meta if needed."""
|
||||
if path_key in self._session_branches:
|
||||
return self._session_branches[path_key]
|
||||
|
||||
# Read first line of JSONL for session_meta
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line:
|
||||
meta = _json.loads(first_line)
|
||||
if meta.get('type') == 'session_meta':
|
||||
cwd = meta.get('payload', {}).get('cwd', '')
|
||||
branch = self._branch_from_cwd(cwd)
|
||||
if branch:
|
||||
self._session_branches[path_key] = branch
|
||||
return branch
|
||||
except (OSError, _json.JSONDecodeError) as e:
|
||||
if path_key not in self._branch_warn_logged:
|
||||
logger.info(f"[monitor] Could not read Codex session_meta: {e}")
|
||||
self._branch_warn_logged.add(path_key)
|
||||
cwd = self._read_codex_cwd(file_path)
|
||||
branch = self._branch_from_cwd(cwd) if cwd else None
|
||||
result = branch or 'CODEX'
|
||||
self._session_branches[path_key] = result
|
||||
return result
|
||||
|
||||
self._session_branches[path_key] = 'CODEX'
|
||||
return 'CODEX'
|
||||
def _resolve_gemini_slug(self, slug: str) -> Optional[str]:
|
||||
"""Resolve a Gemini project slug to a branch name via projects.json."""
|
||||
projects_file = Path.home() / '.gemini' / 'projects.json'
|
||||
if not projects_file.exists():
|
||||
return None
|
||||
try:
|
||||
data = _json.loads(projects_file.read_text())
|
||||
except (OSError, _json.JSONDecodeError) as e:
|
||||
logger.info(f"[monitor] Could not read Gemini projects.json: {e}")
|
||||
return None
|
||||
for project_path, project_slug in data.get('projects', {}).items():
|
||||
if project_slug == slug:
|
||||
return self._branch_from_cwd(project_path)
|
||||
return None
|
||||
|
||||
def _get_gemini_branch(self, file_path, path_key: str) -> str:
|
||||
"""Get branch for a Gemini session from project slug in path."""
|
||||
if path_key in self._session_branches:
|
||||
return self._session_branches[path_key]
|
||||
|
||||
# Path format: ~/.gemini/tmp/<project-slug>/chats/session-*.json
|
||||
# Project slug maps to a directory via ~/.gemini/projects.json
|
||||
# Path: ~/.gemini/tmp/<project-slug>/chats/session-*.json
|
||||
parts = Path(file_path).parts
|
||||
slug = None
|
||||
if 'tmp' in parts:
|
||||
idx = parts.index('tmp')
|
||||
if idx + 1 < len(parts):
|
||||
slug = parts[idx + 1]
|
||||
# Try to resolve slug to real branch via projects.json
|
||||
projects_file = Path.home() / '.gemini' / 'projects.json'
|
||||
if projects_file.exists():
|
||||
try:
|
||||
data = _json.loads(projects_file.read_text())
|
||||
for project_path, project_slug in data.get('projects', {}).items():
|
||||
if project_slug == slug:
|
||||
branch = self._branch_from_cwd(project_path)
|
||||
if branch:
|
||||
self._session_branches[path_key] = branch
|
||||
return branch
|
||||
except (OSError, _json.JSONDecodeError):
|
||||
pass
|
||||
# Fallback: use slug as branch name
|
||||
self._session_branches[path_key] = slug.upper()
|
||||
return slug.upper()
|
||||
|
||||
self._session_branches[path_key] = 'GEMINI'
|
||||
return 'GEMINI'
|
||||
if not slug:
|
||||
self._session_branches[path_key] = 'GEMINI'
|
||||
return 'GEMINI'
|
||||
|
||||
branch = self._resolve_gemini_slug(slug) or slug.upper()
|
||||
self._session_branches[path_key] = branch
|
||||
return branch
|
||||
|
||||
# =========================================================================
|
||||
# MODEL TAG HELPERS
|
||||
@@ -338,35 +343,32 @@ class MonitoringFileHandler(FileSystemEventHandler):
|
||||
return None
|
||||
|
||||
if entry_type == 'response_item':
|
||||
item = payload.get('item', payload)
|
||||
item_type = item.get('type', '')
|
||||
if item_type == 'function_call':
|
||||
name = item.get('name', 'tool')
|
||||
args = item.get('arguments', '')
|
||||
if isinstance(args, str) and len(args) > 80:
|
||||
args = args[:80] + '...'
|
||||
return f"🔧 {name}"
|
||||
if item_type == 'function_call_output':
|
||||
return None # Skip output events
|
||||
if item_type == 'message':
|
||||
content = item.get('content', [])
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
text = part.get('text', '')
|
||||
if text:
|
||||
return f"💬 {text[:120]}"
|
||||
return '💬 Agent response'
|
||||
return MonitoringFileHandler._parse_codex_response_item(payload)
|
||||
|
||||
if entry_type in ('session_meta', 'turn_context'):
|
||||
return None
|
||||
|
||||
if entry_type == 'session_meta':
|
||||
return None # Skip metadata
|
||||
|
||||
if entry_type == 'turn_context':
|
||||
return None # Skip context snapshots
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_codex_response_item(payload: dict) -> Optional[str]:
|
||||
"""Parse a Codex response_item payload into a display string."""
|
||||
item = payload.get('item', payload)
|
||||
item_type = item.get('type', '')
|
||||
if item_type == 'function_call':
|
||||
return f"🔧 {item.get('name', 'tool')}"
|
||||
if item_type == 'function_call_output':
|
||||
return None
|
||||
if item_type != 'message':
|
||||
return None
|
||||
content = item.get('content', [])
|
||||
if not isinstance(content, list):
|
||||
return '💬 Agent response'
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('text'):
|
||||
return f"💬 {part['text'][:120]}"
|
||||
return '💬 Agent response'
|
||||
|
||||
# =========================================================================
|
||||
# GEMINI AGENT ACTIVITY PARSING (full JSON sessions)
|
||||
# =========================================================================
|
||||
@@ -375,37 +377,31 @@ class MonitoringFileHandler(FileSystemEventHandler):
|
||||
def _extract_gemini_action(message: dict) -> Optional[str]:
|
||||
"""Extract a display action string from a Gemini session message."""
|
||||
msg_type = message.get('type', '')
|
||||
|
||||
if msg_type == 'user':
|
||||
return '📩 User message'
|
||||
if msg_type != 'gemini':
|
||||
return None
|
||||
|
||||
if msg_type == 'gemini':
|
||||
# Check for tool calls first
|
||||
tool_calls = message.get('toolCalls', [])
|
||||
if tool_calls:
|
||||
last_tool = tool_calls[-1]
|
||||
name = last_tool.get('displayName', last_tool.get('name', 'tool'))
|
||||
return f"🔧 {name}"
|
||||
tool_calls = message.get('toolCalls', [])
|
||||
if tool_calls:
|
||||
last_tool = tool_calls[-1]
|
||||
return f"🔧 {last_tool.get('displayName', last_tool.get('name', 'tool'))}"
|
||||
|
||||
# Check for thoughts
|
||||
thoughts = message.get('thoughts', [])
|
||||
if thoughts:
|
||||
return '💭 Thinking'
|
||||
if message.get('thoughts'):
|
||||
return '💭 Thinking'
|
||||
|
||||
# Text response
|
||||
content = message.get('content', [])
|
||||
if isinstance(content, list) and content:
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
text = part.get('text', '').strip()
|
||||
if text:
|
||||
return f"💬 {text[:120]}"
|
||||
elif isinstance(content, str) and content.strip():
|
||||
return f"💬 {content.strip()[:120]}"
|
||||
return MonitoringFileHandler._extract_text_from_content(message.get('content', []))
|
||||
|
||||
return '💬 Agent response'
|
||||
|
||||
return None
|
||||
@staticmethod
|
||||
def _extract_text_from_content(content) -> str:
|
||||
"""Extract first text snippet from a content field (list or string)."""
|
||||
if isinstance(content, str) and content.strip():
|
||||
return f"💬 {content.strip()[:120]}"
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('text', '').strip():
|
||||
return f"💬 {part['text'].strip()[:120]}"
|
||||
return '💬 Agent response'
|
||||
|
||||
def _parse_gemini_activity(self, file_path, branch):
|
||||
"""Parse Gemini session JSON to show agent actions.
|
||||
@@ -458,64 +454,70 @@ class MonitoringFileHandler(FileSystemEventHandler):
|
||||
logger.info(f"[monitor] Gemini parse error for {file_path.name}: {e}")
|
||||
return False
|
||||
|
||||
def _parse_codex_activity(self, file_path, branch):
|
||||
"""Parse Codex session JSONL to show agent actions.
|
||||
def _read_new_jsonl_lines(self, file_path, path_key: str) -> Optional[list]:
|
||||
"""Read new lines from a JSONL file since last position. Returns None if no new data."""
|
||||
current_size = file_path.stat().st_size
|
||||
last_pos = self._jsonl_positions.get(path_key, 0)
|
||||
if current_size < last_pos:
|
||||
last_pos = 0
|
||||
if current_size <= last_pos:
|
||||
return None
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
f.seek(last_pos)
|
||||
new_data = f.read()
|
||||
self._jsonl_positions[path_key] = f.tell()
|
||||
lines = [l for l in new_data.strip().split('\n') if l.strip()]
|
||||
return lines if lines else None
|
||||
|
||||
Same JSONL tail-and-parse approach as Claude Code, but uses
|
||||
_extract_codex_action() for Codex-specific event types.
|
||||
"""
|
||||
def _safe_parse_json(self, line: str, label: str) -> Optional[dict]:
|
||||
"""Parse a JSON line, logging errors. Returns None on failure."""
|
||||
try:
|
||||
return _json.loads(line)
|
||||
except _json.JSONDecodeError as e:
|
||||
logger.info(f"[monitor] Skipping malformed {label} JSONL: {e}")
|
||||
return None
|
||||
|
||||
def _emit_agent_event(self, path_key: str, branch: str, action_text: str) -> bool:
|
||||
"""Emit an agent activity event, deduplicating against last action."""
|
||||
if self._last_agent_action.get(path_key) == action_text:
|
||||
return True
|
||||
self._last_agent_action[path_key] = action_text
|
||||
tagged_branch = self._tag_branch_with_model(path_key, branch)
|
||||
evt = MonitoringEvent(
|
||||
priority=1, event_type='agent', branch=tagged_branch,
|
||||
action='activity', message=action_text, level='info',
|
||||
)
|
||||
if self._event_queue:
|
||||
self._event_queue.enqueue(evt)
|
||||
return True
|
||||
|
||||
def _extract_codex_model(self, entry: dict, path_key: str) -> None:
|
||||
"""Extract model from a Codex JSONL entry if present."""
|
||||
entry_type = entry.get('type', '')
|
||||
payload = entry.get('payload', {})
|
||||
model = ''
|
||||
if entry_type == 'turn_context':
|
||||
model = payload.get('model', '')
|
||||
if model:
|
||||
self._session_models[path_key] = self._shorten_model(model)
|
||||
|
||||
def _parse_codex_activity(self, file_path, branch):
|
||||
"""Parse Codex session JSONL to show agent actions."""
|
||||
try:
|
||||
path_key = str(file_path)
|
||||
current_size = file_path.stat().st_size
|
||||
last_pos = self._jsonl_positions.get(path_key, 0)
|
||||
|
||||
if current_size < last_pos:
|
||||
last_pos = 0
|
||||
if current_size <= last_pos:
|
||||
return True
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
f.seek(last_pos)
|
||||
new_data = f.read()
|
||||
self._jsonl_positions[path_key] = f.tell()
|
||||
|
||||
lines = [l for l in new_data.strip().split('\n') if l.strip()]
|
||||
if not lines:
|
||||
lines = self._read_new_jsonl_lines(file_path, path_key)
|
||||
if lines is None:
|
||||
return True
|
||||
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
entry = _json.loads(line)
|
||||
except _json.JSONDecodeError as e:
|
||||
logger.info(f"[monitor] Skipping malformed Codex JSONL: {e}")
|
||||
entry = self._safe_parse_json(line, "Codex")
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
# Extract model from session_meta or response_item
|
||||
if entry.get('type') == 'session_meta':
|
||||
model = entry.get('payload', {}).get('model', '')
|
||||
if model:
|
||||
self._session_models[path_key] = self._shorten_model(model)
|
||||
elif entry.get('type') == 'response_item':
|
||||
model = entry.get('payload', {}).get('item', {}).get('model', '')
|
||||
if model:
|
||||
self._session_models[path_key] = self._shorten_model(model)
|
||||
|
||||
self._extract_codex_model(entry, path_key)
|
||||
action_text = self._extract_codex_action(entry)
|
||||
if not action_text:
|
||||
continue
|
||||
|
||||
if self._last_agent_action.get(path_key) == action_text:
|
||||
return True
|
||||
self._last_agent_action[path_key] = action_text
|
||||
|
||||
tagged_branch = self._tag_branch_with_model(path_key, branch)
|
||||
evt = MonitoringEvent(
|
||||
priority=1, event_type='agent', branch=tagged_branch,
|
||||
action='activity', message=action_text, level='info',
|
||||
)
|
||||
if self._event_queue:
|
||||
self._event_queue.enqueue(evt)
|
||||
return True
|
||||
return self._emit_agent_event(path_key, branch, action_text)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -202,11 +202,21 @@ def print_help() -> None:
|
||||
console.print(" [green]drone @seedgo diagnostics @flow[/green] [dim]# Single branch diagnostics[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Proof (Self-Check):[/yellow]")
|
||||
console.print(" [green]drone @seedgo proof[/green] [dim]# List available proof packs[/dim]")
|
||||
console.print(" [green]drone @seedgo proof aipass[/green] [dim]# Run all proofs for aipass pack[/dim]")
|
||||
console.print(" [green]drone @seedgo proof_query[/green] [dim]# Query proof pack content[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Test Map:[/yellow]")
|
||||
console.print(" [green]drone @seedgo test_map @flow[/green] [dim]# Function test coverage map for branch[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
# Commands line for drone discovery
|
||||
console.print("[dim]Commands: audit, standards_audit, standards_query, checklist, diagnostics, diagnostics_audit, readme, readme_update, --help[/dim]")
|
||||
console.print("[dim]Commands: audit, standards_audit, standards_query, checklist, diagnostics, diagnostics_audit, proof, proof_query, test_map, readme, readme_update, --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,15 @@ def handle_delete(args: list[str]) -> int:
|
||||
|
||||
Returns exit code (0=success, 1=failure).
|
||||
"""
|
||||
# Intercept --help before processing (argparse has add_help=False)
|
||||
if "--help" in args or "-h" in args:
|
||||
warning("Usage: drone @spawn delete <@branch> [--yes] [--dry-run]")
|
||||
console.print()
|
||||
console.print(" [green]@branch[/green] Branch to archive and deregister")
|
||||
console.print(" [green]--yes[/green] Skip confirmation prompt")
|
||||
console.print(" [green]--dry-run[/green] Preview what would happen without changes")
|
||||
return 0
|
||||
|
||||
if not args:
|
||||
warning("Usage: drone @spawn delete <@branch> [--yes] [--dry-run]")
|
||||
console.print()
|
||||
|
||||
@@ -82,6 +82,17 @@ def handle_update(args: list[str]) -> int:
|
||||
|
||||
Returns exit code (0=success, 1=failure).
|
||||
"""
|
||||
# Intercept --help before processing (argparse has add_help=False)
|
||||
if "--help" in args or "-h" in args:
|
||||
warning("Usage: drone @spawn update <@branch|class --all> [--dry-run] [--trace]")
|
||||
console.print()
|
||||
console.print(" [green]@branch[/green] Update a single branch (uses its own class)")
|
||||
console.print(" [green]builder --all[/green] Update all builder-class branches")
|
||||
console.print(" [green]birthright --all[/green] Update all birthright-class branches")
|
||||
console.print(" [green]--dry-run[/green] Preview changes without modifying files")
|
||||
console.print(" [green]--trace[/green] Enable verbose logging")
|
||||
return 0
|
||||
|
||||
if not args:
|
||||
warning("Usage: drone @spawn update <@branch|class --all> [--dry-run] [--trace]")
|
||||
console.print()
|
||||
|
||||
Reference in New Issue
Block a user