Merge pull request #247 from AIOSAI/system/devpulse-s86-memory-bypass-update-trigger-dead-code-removal
feat(system): S86: memory bypass update + trigger dead code removal (10 orphaned functions from DPLAN-0112)
This commit is contained in:
+1
-15
@@ -1,24 +1,10 @@
|
||||
# Pre-commit hooks for AIPass
|
||||
# Install: pip install pre-commit && pre-commit install
|
||||
<<<<<<< HEAD
|
||||
# Manual run: pre-commit run --all-files
|
||||
|
||||
repos:
|
||||
# Gitleaks — secret detection
|
||||
=======
|
||||
# Run manually: pre-commit run --all-files
|
||||
|
||||
repos:
|
||||
>>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention)
|
||||
# Gitleaks — secret detection
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.21.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
name: gitleaks (secret detection)
|
||||
description: Detect hardcoded secrets using gitleaks
|
||||
entry: gitleaks protect --verbose --redact --staged
|
||||
language: golang
|
||||
pass_filenames: false
|
||||
>>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
> The living record. What happened, what's changing, what matters.
|
||||
|
||||
**Last updated:** 2026-04-10 | **Session:** 84 | **PRs merged:** 185
|
||||
**Last updated:** 2026-04-10 | **Session:** 86 | **PRs merged:** 230+
|
||||
|
||||
---
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
- **11 core agents** operational (streamlined from 15 — backup, daemon, commons, skills split to standalone projects; api reinstated)
|
||||
- **3,500+ tests** system-wide
|
||||
- **185 PRs** merged
|
||||
- **84 sessions** of development
|
||||
- **230+ PRs** merged
|
||||
- **86 sessions** of development
|
||||
- **Multi-CLI support** — Claude Code, Codex (GPT-5.4), Gemini all integrated with hooks, identity, skills
|
||||
- **Vera Studio** — standalone AI-driven brand studio for promotion (separate from devpulse construction)
|
||||
- **aipass init v2** — real templates, next steps, `aipass init agent` command
|
||||
@@ -22,8 +22,20 @@
|
||||
|
||||
## Recent Sessions
|
||||
|
||||
### S82 — Core 10 Split: Dependency Audit + README Update (2026-04-09)
|
||||
Decided to split 5 agents to standalone projects (backup, daemon, api, commons, skills). 6 parallel agents verified zero code dependencies from core 10 → split 5. Updated README from 15 → 10 agents. All agent tables, tree diagrams, TOC, metrics updated. CLI dispatched for src/ directory in init + CWD-aware sync-registry for spawn.
|
||||
### S86 — Autonomous APLAN Fix Sweep (2026-04-10)
|
||||
6-hour autonomous session (DPLAN-0111). 11 CRITICALs resolved, 35+ BUGs fixed across flow, api, cli, trigger, drone. Logger catch-all added to all 10 entry points. Seedgo full audit dispatched. Spawn 12 BUGs dispatched.
|
||||
|
||||
### S85 — Trigger Medic v2 Self-Healing Pipeline (2026-04-10)
|
||||
Full autonomous error cycle proven: detect → dispatch → wake → fix → report. Trigger Medic v2 fixed (log_watcher dedup, count gate, wake_branch). Drone git fix/sync upgraded (merge not reset). OSS Health badge. 13 stale plans closed. PRs #229-230.
|
||||
|
||||
### S84 — API Key Incident Recovery + Branch Purge (2026-04-09)
|
||||
Watchdog fixed. Night shift: 4 DPLANs (0107-0110), 10 branches dispatched, PRs #214-226. API key incident recovery. 28 stale branches purged.
|
||||
|
||||
### S83 — Adversarial Audit (2026-04-09)
|
||||
22 Opus adversarial audit agents: 350+ findings across all 11 branches, 11 CRITICALs identified. All 11 APLANs updated by builder agents. Hook-sounds plugin built. PRs #211-212.
|
||||
|
||||
### S82 — Core Split: Dependency Audit + README Update (2026-04-09)
|
||||
Decided to split 4 agents to standalone projects (backup, daemon, commons, skills); api reinstated as infrastructure. 6 parallel agents verified zero code dependencies. Updated README from 15 → 11 agents. All agent tables, tree diagrams, TOC, metrics updated. CLI dispatched for src/ directory in init + CWD-aware sync-registry for spawn.
|
||||
|
||||
### S81 — aipass init v2 + Vera Studio (2026-04-08)
|
||||
TDPLAN-0002: Complete init overhaul. CLI, spawn, drone worked in parallel. Init now creates 10 items with real content (CLAUDE.md, AGENTS.md, GEMINI.md, global prompt, README, .gitignore, hooks, settings). `aipass init agent` routes to spawn. Spawn added --template flag + CLAUDE.md to builder template. Drone added spawn to routing_config.json. Prax fixed watchdog with --daemon mode + statusline indicator. Vera Studio project created. Patrick testing as real first-time user — found .trinity shouldn't be in project root, local prompt is agent-level only. CLI fixed both. DPLAN-0105 (promotion prep), DPLAN-0106 (watchdog v2). PRs #204-205.
|
||||
@@ -101,6 +113,10 @@ Every branch, every standard, 100%. PR #137 (167 files, +12,843 lines).
|
||||
|
||||
| Date | Milestone |
|
||||
|------|-----------|
|
||||
| 2026-04-10 | S86: Autonomous APLAN fix sweep — 11 CRITICALs resolved, 35+ BUGs fixed |
|
||||
| 2026-04-10 | S85: Trigger Medic v2 self-healing pipeline live |
|
||||
| 2026-04-09 | S84: API key incident recovery + 28 stale branches purged |
|
||||
| 2026-04-09 | S83: Adversarial audit (22 agents, 350+ findings, 11 CRITICALs) |
|
||||
| 2026-04-05 | Multi-CLI integration — Codex + Gemini fully integrated with hooks, skills, prax model tags |
|
||||
| 2026-04-05 | README overhaul — grouped branch tables, compliance section, navigation |
|
||||
| 2026-04-03 | S73 night shift — 1,120 new tests, 7 branches at 100% module coverage |
|
||||
@@ -124,10 +140,10 @@ Every branch, every standard, 100%. PR #137 (167 files, +12,843 lines).
|
||||
```
|
||||
Branches: 11
|
||||
Standards: 33
|
||||
Tests: 3,500+
|
||||
PRs merged: 185
|
||||
Sessions: 84
|
||||
Diagnostic tools: 21
|
||||
Tests: ~3,500
|
||||
PRs merged: 230+
|
||||
Sessions: 86
|
||||
Diagnostic tools: 26
|
||||
CLIs supported: 3 (Claude Code, Codex, Gemini)
|
||||
```
|
||||
|
||||
|
||||
@@ -130,6 +130,11 @@
|
||||
"standard": "naming",
|
||||
"reason": "False positive — _inbox_lock is a lazy-import function reference, not a module-level constant."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/email/inbox_ops.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive — _inbox_lock is a lazy-import function reference, not a module-level constant."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/email/inbox_resolve.py",
|
||||
"standard": "naming",
|
||||
|
||||
@@ -206,39 +206,43 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands to modules"""
|
||||
try:
|
||||
# Parse arguments
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Parse arguments
|
||||
args = sys.argv[1:]
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
return 0
|
||||
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
return 0
|
||||
# Show version
|
||||
if args[0] in ['--version', '-V']:
|
||||
console.print("AI_MAIL v1.0.0")
|
||||
return 0
|
||||
|
||||
# Show version
|
||||
if args[0] in ['--version', '-V']:
|
||||
console.print("AI_MAIL v1.0.0")
|
||||
return 0
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
return 0
|
||||
# Command provided - try to route to modules
|
||||
modules = discover_modules()
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Command provided - try to route to modules
|
||||
modules = discover_modules()
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
if not modules:
|
||||
error("No modules found")
|
||||
return 1
|
||||
|
||||
if not modules:
|
||||
error("No modules found")
|
||||
return 1
|
||||
# Route command
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
else:
|
||||
error(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
# Route command
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
else:
|
||||
error(f"Unknown command: {command}")
|
||||
except Exception as exc:
|
||||
logger.error('[ai_mail] Unhandled error in main: %s', exc)
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -98,7 +98,8 @@ def _check_rate_limited(stderr_log: str) -> bool:
|
||||
lower = content.lower()
|
||||
return ("rate_limit" in lower or "429" in content or
|
||||
"overloaded" in lower or "529" in content)
|
||||
except OSError:
|
||||
except OSError as e:
|
||||
logger.warning("[monitor] _check_rate_limited failed reading %s: %s", stderr_log, e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -122,10 +123,10 @@ def _snapshot_jsonl_sizes(projects_dir: Path) -> dict:
|
||||
for f in projects_dir.glob("*.jsonl"):
|
||||
try:
|
||||
sizes[f.name] = f.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.warning("[monitor] _snapshot_jsonl_sizes stat failed for %s: %s", f.name, e)
|
||||
except OSError as e:
|
||||
logger.warning("[monitor] _snapshot_jsonl_sizes glob failed for %s: %s", projects_dir, e)
|
||||
return sizes
|
||||
|
||||
|
||||
@@ -145,10 +146,10 @@ def _check_jsonl_activity(projects_dir: Path, initial_sizes: dict) -> bool:
|
||||
elif current_size > initial_sizes[name]:
|
||||
# Existing file grew
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.warning("[monitor] _check_jsonl_activity stat failed for %s: %s", f.name, e)
|
||||
except OSError as e:
|
||||
logger.warning("[monitor] _check_jsonl_activity glob failed for %s: %s", projects_dir, e)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ api/
|
||||
|
||||
### Provides To
|
||||
- All branches -- authenticated external API clients
|
||||
- `@backup` -- Google Drive service (migration from self-contained auth)
|
||||
- `@skills` -- future external API integrations (Telegram, Google services)
|
||||
- System-wide API key management and credential validation
|
||||
|
||||
### Credentials
|
||||
|
||||
+40
-36
@@ -235,51 +235,55 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands to modules"""
|
||||
try:
|
||||
# Parse arguments directly from sys.argv
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Parse arguments directly from sys.argv
|
||||
args = sys.argv[1:]
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
json_handler.log_operation("api_introspection_displayed", {"trigger": "no_args"})
|
||||
return 0
|
||||
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
json_handler.log_operation("api_introspection_displayed", {"trigger": "no_args"})
|
||||
return 0
|
||||
# Show version
|
||||
if args[0] in ['--version', '-V']:
|
||||
console.print("API v1.0.0")
|
||||
return 0
|
||||
|
||||
# Show version
|
||||
if args[0] in ['--version', '-V']:
|
||||
console.print("API v1.0.0")
|
||||
return 0
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
json_handler.log_operation("api_help_displayed", {"trigger": args[0]})
|
||||
return 0
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
json_handler.log_operation("api_help_displayed", {"trigger": args[0]})
|
||||
return 0
|
||||
# Discover modules
|
||||
modules = discover_modules()
|
||||
|
||||
# Discover modules
|
||||
modules = discover_modules()
|
||||
if not modules:
|
||||
logger.error("No modules found")
|
||||
error("No modules found")
|
||||
return 1
|
||||
|
||||
if not modules:
|
||||
logger.error("No modules found")
|
||||
error("No modules found")
|
||||
return 1
|
||||
# Extract command and remaining args (matching seedgo pattern)
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Extract command and remaining args (matching seedgo pattern)
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
# Log api command attempt
|
||||
json_handler.log_operation(
|
||||
"api_command_attempted",
|
||||
{"command": command, "modules_discovered": len(modules)}
|
||||
)
|
||||
|
||||
# Log api command attempt
|
||||
json_handler.log_operation(
|
||||
"api_command_attempted",
|
||||
{"command": command, "modules_discovered": len(modules)}
|
||||
)
|
||||
# Route command to modules
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
else:
|
||||
logger.warning(f"Unknown command: {command}")
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
|
||||
return 1
|
||||
|
||||
# Route command to modules
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
else:
|
||||
logger.warning(f"Unknown command: {command}")
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
|
||||
except Exception as exc:
|
||||
logger.error("[api] Unhandled error in main: %s", exc)
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -42,7 +42,6 @@ MODULE_VERSION = "1.0.0"
|
||||
CALLER_PATTERNS = {
|
||||
"flow": "flow_json",
|
||||
"prax": "prax_json",
|
||||
"skills": "{category}_json",
|
||||
}
|
||||
|
||||
# =============================================
|
||||
@@ -70,11 +69,6 @@ def get_caller_info() -> Optional[Dict[str, Any]]:
|
||||
result = _detect_prax_caller(frame_path)
|
||||
json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "prax"})
|
||||
return result
|
||||
elif any("skills" in part for part in frame_path.parts):
|
||||
result = _detect_skills_caller(frame_path)
|
||||
json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "skills"})
|
||||
return result
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] Could not detect caller from stack trace")
|
||||
return None
|
||||
|
||||
@@ -105,8 +99,6 @@ def detect_caller_category(caller_path: Path) -> str:
|
||||
return "flow"
|
||||
elif "prax" in path_parts:
|
||||
return "prax"
|
||||
elif any("skills" in part for part in path_parts):
|
||||
return "skills"
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
@@ -165,38 +157,6 @@ def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]:
|
||||
return _create_fallback_info(frame_path)
|
||||
|
||||
|
||||
def _detect_skills_caller(frame_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Detect skills module caller from stack frame path.
|
||||
Skills have category subdirectories (e.g., /skills/skills_api/skill.py)
|
||||
"""
|
||||
try:
|
||||
for i, part in enumerate(frame_path.parts):
|
||||
if "skills" in part:
|
||||
skills_path = Path(*frame_path.parts[:i + 2])
|
||||
category = frame_path.parts[i + 1] if i + 1 < len(frame_path.parts) else "skills_api"
|
||||
json_folder_path = skills_path / f"{category}_json"
|
||||
caller_name = frame_path.stem
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] Detected skills caller: {caller_name} (category: {category})")
|
||||
|
||||
return {
|
||||
"caller_name": caller_name,
|
||||
"caller_path": frame_path,
|
||||
"json_folder": json_folder_path,
|
||||
"category": "skills",
|
||||
"skills_category": category,
|
||||
"detection_method": "stack"
|
||||
}
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] Could not find skills directory in path: {frame_path}")
|
||||
return _create_fallback_info(frame_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to detect skills caller: {e}")
|
||||
return _create_fallback_info(frame_path)
|
||||
|
||||
|
||||
def _create_fallback_info(frame_path: Path) -> Dict[str, Any]:
|
||||
"""Create fallback caller info when detection fails."""
|
||||
caller_name = frame_path.stem
|
||||
|
||||
@@ -256,13 +256,17 @@ def extract_response(response: Any) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
# Extract metadata
|
||||
finish_reason = response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None
|
||||
result = {
|
||||
"content": content,
|
||||
"id": response.id if hasattr(response, 'id') else None,
|
||||
"model": response.model if hasattr(response, 'model') else None,
|
||||
"finish_reason": response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None
|
||||
"finish_reason": finish_reason
|
||||
}
|
||||
|
||||
if finish_reason == "content_filter":
|
||||
logger.warning("Response was truncated or blocked by content filter")
|
||||
|
||||
logger.info(f"Extracted response - length: {len(content)} chars, id: {result['id']}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -172,9 +172,9 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
|
||||
Create new caller configuration with defaults
|
||||
|
||||
Creates complete 3-file JSON structure:
|
||||
- openrouter_skill_config.json (API settings)
|
||||
- openrouter_skill_data.json (usage tracking)
|
||||
- openrouter_skill_log.json (operation log)
|
||||
- openrouter_config.json (API settings)
|
||||
- openrouter_data.json (usage tracking)
|
||||
- openrouter_log.json (operation log)
|
||||
|
||||
Args:
|
||||
caller: Name of calling module
|
||||
@@ -189,7 +189,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
# Create config file
|
||||
config_file = json_folder / "openrouter_skill_config.json"
|
||||
config_file = json_folder / "openrouter_config.json"
|
||||
config = get_default_caller_config()
|
||||
|
||||
if not write_json(config_file, config):
|
||||
@@ -199,7 +199,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
|
||||
logger.info(f"Created config: {config_file.name}")
|
||||
|
||||
# Create data file
|
||||
data_file = json_folder / "openrouter_skill_data.json"
|
||||
data_file = json_folder / "openrouter_data.json"
|
||||
data = get_default_caller_data()
|
||||
|
||||
if write_json(data_file, data):
|
||||
@@ -207,7 +207,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]:
|
||||
logger.info(f"Created data: {data_file.name}")
|
||||
|
||||
# Create log file
|
||||
log_file = json_folder / "openrouter_skill_log.json"
|
||||
log_file = json_folder / "openrouter_log.json"
|
||||
log_data = get_default_caller_log()
|
||||
|
||||
if write_json(log_file, log_data):
|
||||
@@ -258,7 +258,7 @@ def ensure_caller_config(caller: str | None = None) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
# Check if config already exists
|
||||
config_file = json_folder / "openrouter_skill_config.json"
|
||||
config_file = json_folder / "openrouter_config.json"
|
||||
|
||||
if config_file.exists():
|
||||
config = read_json(config_file)
|
||||
|
||||
@@ -101,7 +101,7 @@ def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTI
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup failed: {e}")
|
||||
raise
|
||||
return 0
|
||||
|
||||
|
||||
def _identify_old_generations(generation_tracking: Dict, cutoff_date: datetime) -> List[str]:
|
||||
|
||||
@@ -49,6 +49,7 @@ GENERATION_ENDPOINT = f"{OPENROUTER_BASE_URL}/generation"
|
||||
# Default configuration values
|
||||
DEFAULT_GENERATION_CHECK_DELAY = 2 # seconds to wait before querying metrics
|
||||
DEFAULT_REQUEST_TIMEOUT = 30 # seconds for HTTP request timeout
|
||||
MAX_GENERATION_TRACKING = 500 # Maximum entries in generation_tracking before trimming
|
||||
|
||||
|
||||
# =============================================
|
||||
@@ -161,15 +162,15 @@ def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[st
|
||||
logger.warning(f"[{MODULE_NAME}] Invalid response structure from OpenRouter for generation {generation_id}")
|
||||
return None
|
||||
|
||||
# Extract metrics from response
|
||||
# Extract metrics from response (use `or 0` to handle explicit None values)
|
||||
metrics = data["data"]
|
||||
result = {
|
||||
"total_cost": float(metrics.get("total_cost", 0)),
|
||||
"tokens_prompt": int(metrics.get("tokens_prompt", 0)),
|
||||
"tokens_completion": int(metrics.get("tokens_completion", 0)),
|
||||
"generation_time": int(metrics.get("generation_time", 0)),
|
||||
"latency": int(metrics.get("latency", 0)),
|
||||
"provider_name": metrics.get("provider_name", "unknown")
|
||||
"total_cost": float(metrics.get("total_cost") or 0),
|
||||
"tokens_prompt": int(metrics.get("tokens_prompt") or 0),
|
||||
"tokens_completion": int(metrics.get("tokens_completion") or 0),
|
||||
"generation_time": int(metrics.get("generation_time") or 0),
|
||||
"latency": int(metrics.get("latency") or 0),
|
||||
"provider_name": metrics.get("provider_name") or "unknown"
|
||||
}
|
||||
|
||||
# Retrieved metrics for generation_id
|
||||
@@ -281,6 +282,20 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[
|
||||
current_data["daily_totals"][today]["cost"] += metrics["total_cost"]
|
||||
current_data["daily_totals"][today]["tokens"] += total_tokens
|
||||
|
||||
# Update monthly totals
|
||||
month = today[:7] # YYYY-MM
|
||||
if "monthly_totals" not in current_data:
|
||||
current_data["monthly_totals"] = {}
|
||||
if month not in current_data["monthly_totals"]:
|
||||
current_data["monthly_totals"][month] = {
|
||||
"requests": 0,
|
||||
"cost": 0.0,
|
||||
"tokens": 0
|
||||
}
|
||||
current_data["monthly_totals"][month]["requests"] += 1
|
||||
current_data["monthly_totals"][month]["cost"] += metrics["total_cost"]
|
||||
current_data["monthly_totals"][month]["tokens"] += total_tokens
|
||||
|
||||
# Store generation details with newest-first ordering
|
||||
new_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
@@ -293,6 +308,12 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[
|
||||
current_tracking = current_data["generation_tracking"]
|
||||
current_data["generation_tracking"] = {generation_id: new_entry, **current_tracking}
|
||||
|
||||
# Trim oldest entries if over the size cap
|
||||
if len(current_data["generation_tracking"]) > MAX_GENERATION_TRACKING:
|
||||
keys = list(current_data["generation_tracking"].keys())
|
||||
for old_key in keys[MAX_GENERATION_TRACKING:]:
|
||||
del current_data["generation_tracking"][old_key]
|
||||
|
||||
# Save updated data with proper wrapper structure
|
||||
data_wrapper = {
|
||||
"module_name": "api_usage",
|
||||
|
||||
@@ -12,9 +12,7 @@ Tests for openrouter.caller — caller detection handler.
|
||||
Tests:
|
||||
- detect_caller_category for flow paths
|
||||
- detect_caller_category for prax paths
|
||||
- detect_caller_category for skills paths
|
||||
- detect_caller_category for unknown paths
|
||||
- detect_caller_category with 'skills' as substring in a part
|
||||
- detect_caller_category for unknown paths (including former skills paths)
|
||||
"""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -43,18 +41,13 @@ class TestDetectCallerCategory:
|
||||
path = Path("/home/user/projects/aipass/src/aipass/prax/monitor.py")
|
||||
assert detect_caller_category(path) == "prax"
|
||||
|
||||
def test_skills_exact_part_returns_skills(self):
|
||||
"""Path with exact 'skills' directory should return 'skills'."""
|
||||
def test_skills_path_returns_unknown(self):
|
||||
"""Skills branch was removed — skills paths now return 'unknown'."""
|
||||
path = Path("/home/user/projects/aipass/src/aipass/skills/skills_api/tool.py")
|
||||
assert detect_caller_category(path) == "skills"
|
||||
|
||||
def test_skills_substring_returns_skills(self):
|
||||
"""Path with 'skills' as substring in a part (e.g., 'skills_api') should return 'skills'."""
|
||||
path = Path("/home/user/projects/aipass/src/aipass/modules/skills_custom/handler.py")
|
||||
assert detect_caller_category(path) == "skills"
|
||||
assert detect_caller_category(path) == "unknown"
|
||||
|
||||
def test_unknown_path_returns_unknown(self):
|
||||
"""Path without flow, prax, or skills should return 'unknown'."""
|
||||
"""Path without flow or prax should return 'unknown'."""
|
||||
path = Path("/home/user/projects/aipass/src/aipass/api/apps/handler.py")
|
||||
assert detect_caller_category(path) == "unknown"
|
||||
|
||||
@@ -63,9 +56,9 @@ class TestDetectCallerCategory:
|
||||
path = Path("/home/user/flow/prax/script.py")
|
||||
assert detect_caller_category(path) == "flow"
|
||||
|
||||
def test_prax_takes_priority_over_skills(self):
|
||||
"""If 'prax' appears before a skills part, should return 'prax'."""
|
||||
path = Path("/home/user/prax/skills_module/script.py")
|
||||
def test_prax_in_mixed_path(self):
|
||||
"""'prax' in path should return 'prax' regardless of other parts."""
|
||||
path = Path("/home/user/prax/other_module/script.py")
|
||||
assert detect_caller_category(path) == "prax"
|
||||
|
||||
def test_root_path_returns_unknown(self):
|
||||
|
||||
@@ -46,9 +46,9 @@ def test_create_caller_config_creates_three_files(tmp_path: Path):
|
||||
result = create_caller_config("test_caller", json_folder)
|
||||
|
||||
assert result != {}
|
||||
assert (json_folder / "openrouter_skill_config.json").exists()
|
||||
assert (json_folder / "openrouter_skill_data.json").exists()
|
||||
assert (json_folder / "openrouter_skill_log.json").exists()
|
||||
assert (json_folder / "openrouter_config.json").exists()
|
||||
assert (json_folder / "openrouter_data.json").exists()
|
||||
assert (json_folder / "openrouter_log.json").exists()
|
||||
|
||||
|
||||
def test_create_caller_config_defaults(tmp_path: Path):
|
||||
@@ -57,7 +57,7 @@ def test_create_caller_config_defaults(tmp_path: Path):
|
||||
|
||||
create_caller_config("test_caller", json_folder)
|
||||
|
||||
config = read_json(json_folder / "openrouter_skill_config.json")
|
||||
config = read_json(json_folder / "openrouter_config.json")
|
||||
assert config is not None
|
||||
assert config["config"]["ai_temperature"] == 0.7
|
||||
assert config["config"]["ai_max_tokens"] == 4000
|
||||
@@ -72,7 +72,7 @@ def test_create_caller_config_data_defaults(tmp_path: Path):
|
||||
|
||||
create_caller_config("test_caller", json_folder)
|
||||
|
||||
data = read_json(json_folder / "openrouter_skill_data.json")
|
||||
data = read_json(json_folder / "openrouter_data.json")
|
||||
assert data is not None
|
||||
assert data["data"]["total_requests"] == 0
|
||||
assert data["data"]["successful_requests"] == 0
|
||||
@@ -87,7 +87,7 @@ def test_create_caller_config_log_defaults(tmp_path: Path):
|
||||
|
||||
create_caller_config("test_caller", json_folder)
|
||||
|
||||
log = read_json(json_folder / "openrouter_skill_log.json")
|
||||
log = read_json(json_folder / "openrouter_log.json")
|
||||
assert log is not None
|
||||
assert log["logs"] == []
|
||||
assert log["module_name"] == "openrouter"
|
||||
@@ -143,9 +143,9 @@ def test_ensure_caller_config_provisions_new(mock_detect, tmp_path: Path):
|
||||
result = ensure_caller_config("test_caller")
|
||||
|
||||
assert result != {}
|
||||
assert (json_folder / "openrouter_skill_config.json").exists()
|
||||
assert (json_folder / "openrouter_skill_data.json").exists()
|
||||
assert (json_folder / "openrouter_skill_log.json").exists()
|
||||
assert (json_folder / "openrouter_config.json").exists()
|
||||
assert (json_folder / "openrouter_data.json").exists()
|
||||
assert (json_folder / "openrouter_log.json").exists()
|
||||
|
||||
|
||||
@patch("aipass.api.apps.handlers.openrouter.provision.detect_caller_from_stack")
|
||||
@@ -159,7 +159,7 @@ def test_ensure_caller_config_returns_existing(mock_detect, tmp_path: Path):
|
||||
assert first_result != {}
|
||||
|
||||
# Read the created config and modify it to detect overwrites
|
||||
config_path = json_folder / "openrouter_skill_config.json"
|
||||
config_path = json_folder / "openrouter_config.json"
|
||||
config = read_json(config_path)
|
||||
assert config is not None
|
||||
config["config"]["ai_model"] = "test/modified-model"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
**Purpose:** Display and output formatting service for AIPass modules. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every module looks the same without duplicating Rich formatting code.
|
||||
**Module:** `aipass.cli`
|
||||
**Seedgo:** 100%
|
||||
**Tests:** 138 passing (6 files, 5/5 modules covered)
|
||||
**Last Updated:** 2026-04-07
|
||||
**Tests:** 142 passing (6 files, 5/5 modules covered)
|
||||
**Last Updated:** 2026-04-10
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -66,7 +66,7 @@ cli/
|
||||
├── cli_json/ # Auto-created JSON output (three-file pattern)
|
||||
├── dropbox/ # Inbound file drop
|
||||
├── logs/ # Branch-level logs
|
||||
├── tests/ # 138 tests across 6 files
|
||||
├── tests/ # 142 tests across 6 files
|
||||
│ ├── test_bootstrap.py # bootstrap.py handler tests
|
||||
│ ├── test_json_handler.py # json_handler tests
|
||||
│ ├── test_display.py # display module tests
|
||||
@@ -90,6 +90,7 @@ drone @cli aipass # Show aipass subcommands
|
||||
drone @cli aipass init # Bootstrap AIPass project in current dir
|
||||
drone @cli aipass init /path # Bootstrap in target directory
|
||||
drone @cli aipass init /path MyProject # Bootstrap with custom name
|
||||
drone @cli aipass init agent <name> # Create agent in project (routes to spawn)
|
||||
drone @cli aipass init --help # Detailed init usage
|
||||
drone @cli display # Display module introspection
|
||||
drone @cli display demo # Run display function showcase
|
||||
@@ -119,7 +120,7 @@ aipass --help # Via console_scripts entry point
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-04-07*
|
||||
*Last Updated: 2026-04-10*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -14,6 +14,7 @@ Provides display functions for all branches:
|
||||
- success() - Green checkmark + message
|
||||
- error() - Red X + error message
|
||||
- warning() - Yellow warning + message
|
||||
- fatal() - Error + sys.exit(1)
|
||||
- section() - Visual section breaks
|
||||
|
||||
Uses Rich library for beautiful terminal output.
|
||||
@@ -97,7 +98,7 @@ def print_help():
|
||||
CONSOLE.print("Display is the [bold]CLI's universal output service[/bold] that provides:")
|
||||
# RICH FORMATTING TIP: Use [green]✓[/green] for checkmarks in lists
|
||||
CONSOLE.print(" [green]✓[/green] Consistent Rich-formatted output across all branches")
|
||||
CONSOLE.print(" [green]✓[/green] Five core display functions ([green]header, success, error, warning, section[/green])")
|
||||
CONSOLE.print(" [green]✓[/green] Six core display functions ([green]header, success, error, warning, fatal, section[/green])")
|
||||
CONSOLE.print(" [green]✓[/green] Beautiful terminal output with colors, panels, and formatting")
|
||||
CONSOLE.print(" [green]✓[/green] Integration with CLI error handler for advanced error display")
|
||||
CONSOLE.print()
|
||||
@@ -106,7 +107,7 @@ def print_help():
|
||||
# RICH FORMATTING TIP: Tables are powerful for structured data
|
||||
# Create with Table(), add columns, add rows, then print
|
||||
# =========================================================================
|
||||
CONSOLE.print("[bold cyan]PUBLIC API FUNCTIONS (5 total):[/bold cyan]")
|
||||
CONSOLE.print("[bold cyan]PUBLIC API FUNCTIONS (6 total):[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
|
||||
# RICH FORMATTING TIP: Table styling - show_header, header_style, border_style
|
||||
@@ -119,6 +120,7 @@ def print_help():
|
||||
table.add_row("success()", "message, **kwargs", "Success messages with green checkmark + optional details")
|
||||
table.add_row("error()", "message, suggestion=None", "Error messages with red X + optional suggestion")
|
||||
table.add_row("warning()", "message, details=None", "Warning messages with yellow symbol + optional details")
|
||||
table.add_row("fatal()", "message, suggestion=None", "Error message + sys.exit(1) for unrecoverable failures")
|
||||
table.add_row("section()", "title", "Visual section separators with title and line")
|
||||
|
||||
# RICH FORMATTING TIP: Print the table after adding all rows
|
||||
|
||||
@@ -201,7 +201,12 @@ def _handle_init(args: List[str]) -> bool:
|
||||
|
||||
# Parse positional args: [target_dir] [project_name]
|
||||
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd())
|
||||
target = Path(args[0]) if args else Path(caller_cwd)
|
||||
if args:
|
||||
target = Path(args[0])
|
||||
if not target.is_absolute():
|
||||
target = Path(caller_cwd) / target
|
||||
else:
|
||||
target = Path(caller_cwd)
|
||||
project_name = args[1] if len(args) > 1 else None
|
||||
|
||||
try:
|
||||
@@ -210,10 +215,6 @@ def _handle_init(args: List[str]) -> bool:
|
||||
logger.warning("Init validation error: %s", exc)
|
||||
error(str(exc), suggestion="Pass a project name explicitly")
|
||||
sys.exit(1)
|
||||
except FileExistsError as exc:
|
||||
logger.warning("Init target already exists: %s", exc)
|
||||
error(str(exc))
|
||||
sys.exit(1)
|
||||
except OSError as exc:
|
||||
logger.error("Init filesystem error: %s", exc)
|
||||
error(f"Filesystem error: {exc}")
|
||||
@@ -283,11 +284,12 @@ def _handle_init_agent(args: List[str]) -> bool:
|
||||
|
||||
agent_name = args[0]
|
||||
agent_path = f"src/{agent_name}"
|
||||
extra_flags = args[1:]
|
||||
logger.info("Routing 'init agent %s' to drone @spawn create %s", agent_name, agent_path)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@spawn", "create", agent_path],
|
||||
["drone", "@spawn", "create", agent_path] + extra_flags,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
@@ -297,6 +299,7 @@ def _handle_init_agent(args: List[str]) -> bool:
|
||||
)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.warning("drone command not found on PATH")
|
||||
error(
|
||||
"drone command not found",
|
||||
suggestion="Ensure AIPass is installed and drone is in PATH",
|
||||
|
||||
@@ -60,7 +60,7 @@ def print_help():
|
||||
CONSOLE.print(" Standard operation header with Rich styling")
|
||||
CONSOLE.print(" Example: operation_start('Creating branch', target='/path', type='module')")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" [yellow]operation_complete(success=None, results=None, **summary)[/yellow]")
|
||||
CONSOLE.print(" [yellow]operation_complete(**summary)[/yellow]")
|
||||
CONSOLE.print(" Standard completion summary with Rich styling")
|
||||
CONSOLE.print(" Example: operation_complete(created=5, skipped=2, time='3.2s')")
|
||||
CONSOLE.print()
|
||||
@@ -174,16 +174,15 @@ def operation_start(operation: str, **details) -> None:
|
||||
CONSOLE.print()
|
||||
|
||||
|
||||
def operation_complete(success: bool | None = None, **summary) -> None:
|
||||
def operation_complete(**summary) -> None:
|
||||
"""
|
||||
Display standard operation completion template with Rich styling
|
||||
|
||||
Args:
|
||||
success: True if successful, False if errors
|
||||
**summary: Summary statistics
|
||||
|
||||
Example:
|
||||
operation_complete(True, created=5, skipped=2, time='3.2s')
|
||||
operation_complete(created=5, skipped=2, time='3.2s')
|
||||
"""
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("─" * 50)
|
||||
|
||||
@@ -377,6 +377,24 @@ def test_init_project_skips_existing_optional_files(tmp_path):
|
||||
assert md_content == "# Custom CLAUDE\n"
|
||||
|
||||
|
||||
def test_init_project_no_overwrite(tmp_path):
|
||||
"""Init does not overwrite existing files — re-runnable safety."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
# First run creates files
|
||||
result1 = init_project(target, project_name="safe")
|
||||
assert len(result1["created_files"]) > 0
|
||||
|
||||
# Second run creates nothing — all files skipped
|
||||
result2 = init_project(target, project_name="safe")
|
||||
assert len(result2["created_files"]) == 0
|
||||
|
||||
# Content from first run is preserved
|
||||
claude_md = (target / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "SAFE" in claude_md
|
||||
|
||||
|
||||
def test_init_project_returns_dict(tmp_path):
|
||||
"""init_project return value is a dict."""
|
||||
target = tmp_path / "proj"
|
||||
|
||||
@@ -13,7 +13,7 @@ You are DEVPULSE — orchestration hub. Manager, not builder. Coordinate, plan,
|
||||
- Never block waiting on agents. Never burn context reading code across branches.
|
||||
- Use `drone @branch --help` for command syntax. Use `drone systems` for branch list.
|
||||
- **ALWAYS WAKE after sending dispatch emails.** Send email → wake. Every time. No asking. If the user wants something different, they will say so.
|
||||
- **START WATCHDOG after any dispatch.** Run `python3 src/aipass/prax/tools/inbox_watchdog.py src/aipass/devpulse/.ai_mail.local/inbox.json --interval 30 &` after dispatching. Don't wait for Patrick to ask.
|
||||
- **START WATCHDOG after any dispatch.** Use the watchdog one-liner (see Watchdog section below) with `run_in_background: true`. Don't wait for Patrick to ask.
|
||||
|
||||
## Dispatch, Don't Do
|
||||
|
||||
@@ -66,7 +66,7 @@ drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, devpulse (
|
||||
|
||||
## Your Project
|
||||
|
||||
You have a personal project: **Compass** at `~/Projects/compass/`. It's a vector-based thinking engine for growing autonomous decision-making. The judgment library at `~/Projects/decisions.md` (27 entries) is the prototype data. Compass vectorizes these decisions into ChromaDB fragments so you can query past judgment patterns, feedback, and outcomes when facing new decisions. Own venv, own registry, own orchestration AI (eventually). Work on this when you have autonomy time.
|
||||
You have a personal project: **Compass** at `~/Projects/compass/`. It's a vector-based thinking engine for growing autonomous decision-making. The judgment library at `~/Projects/decisions.md` (27 entries) is the prototype data. Compass vectorizes these decisions into ChromaDB fragments so you can query past judgment patterns, feedback, and outcomes when facing new decisions. Own venv, own registry, own orchestration AI (eventually). Work on this when you have autonomy time. Compass is part of the larger Nexus vision. See decisions.md and key_learning nexus_is_the_answer.
|
||||
|
||||
## Working Habits
|
||||
|
||||
@@ -74,7 +74,7 @@ You have a personal project: **Compass** at `~/Projects/compass/`. It's a vector
|
||||
- **Use memories freely.** Don't hoard or stress about capacity — rollover to @memory is by design. Update `.trinity/` often. More is better.
|
||||
- **STATUS.local.md for friction notes.** When something feels off or could be improved, drop a quick note in the Notepad section. Address in batches later.
|
||||
- **Know your limits.** You're great at planning, coordinating, seeing the big picture. You're bad at hands-on branch-level code tasks. Dispatch, don't do.
|
||||
- **Git awareness as a natural habit.** After completing a feature, merging something, or wrapping up a chunk of work — take a moment to think: "we've been working for a while, what's changed?" Run `git status`, see what's accumulated. If it looks like a coherent set of changes (an upgrade, a fix cycle, a config update), suggest a commit or PR. Don't force it every turn, but don't let 60+ files pile up silently either. Think of it like tidying your desk at the end of a work session — not obsessive, just mindful.
|
||||
- **Git awareness as a natural habit.** After completing a feature or wrapping up a chunk of work, run `git status`. If changes look coherent (upgrade, fix cycle, config update), suggest a commit or PR. Don't force it every turn, but don't let files pile up silently either.
|
||||
## Watchdog — Autonomous Mail Wait
|
||||
|
||||
After dispatching branches, use a background bash wait that exits when mail arrives. This wakes you like a sub-agent completing.
|
||||
@@ -84,19 +84,19 @@ After dispatching branches, use a background bash wait that exits when mail arri
|
||||
# 1. Dispatch work
|
||||
drone @ai_mail dispatch @target "Subject" "Body"
|
||||
|
||||
# 2. Clear inbox first, then arm watchdog (run_in_background: true, timeout: 600000)
|
||||
drone @ai_mail close all
|
||||
INBOX="path/to/inbox.json"; while true; do sleep 10; UNREAD=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$UNREAD" -gt "0" ]; then echo "WOKE: $UNREAD unread"; exit 0; fi; done
|
||||
# 2. Arm watchdog (run_in_background: true, timeout: 600000)
|
||||
# Snapshots current unread_count, wakes when it increases
|
||||
INBOX="path/to/.ai_mail.local/inbox.json"; INITIAL=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); CURRENT=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$CURRENT" -gt "$INITIAL" ]; then echo "WOKE: new mail ($INITIAL→$CURRENT)"; exit 0; fi; done; echo "TIMEOUT"
|
||||
|
||||
# 3. Stop — do nothing until notified
|
||||
# 4. Wake notification arrives → read mail → process → dispatch next → repeat
|
||||
```
|
||||
|
||||
**Key:** Use `unread_count > 0` (not total_messages — close resets totals). Always `close all` before arming. 10s poll interval. `run_in_background: true` so the completion notification wakes you. On timeout, wake anyway to check if agent crashed — then either restart watchdog or re-dispatch.
|
||||
**Key:** Snapshot unread_count BEFORE arming, then wake when it increases. Don't require empty inbox — works with existing mail. 10s poll interval. `run_in_background: true` so the completion notification wakes you. On timeout, wake anyway to check if agent crashed.
|
||||
|
||||
**Watchdog one-liner (copy-paste ready):**
|
||||
```
|
||||
INBOX="/home/patrick/Projects/AIPass/src/aipass/devpulse/.ai_mail.local/inbox.json"; C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); UNREAD=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$UNREAD" -gt "0" ]; then echo "WOKE: $UNREAD unread"; exit 0; fi; done; echo "TIMEOUT: 10min no reply — check if agent crashed"; exit 0
|
||||
INBOX="/home/patrick/Projects/AIPass/src/aipass/devpulse/.ai_mail.local/inbox.json"; INITIAL=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); C=0; while [ $C -lt 60 ]; do sleep 10; C=$((C+1)); CURRENT=$(python3 -c "import json; from pathlib import Path; p=Path('$INBOX'); print(json.loads(p.read_text()).get('unread_count',0) if p.exists() else 0)" 2>/dev/null); if [ "$CURRENT" -gt "$INITIAL" ]; then echo "WOKE: new mail ($INITIAL→$CURRENT)"; exit 0; fi; done; echo "TIMEOUT: 10min no new mail"; exit 0
|
||||
```
|
||||
|
||||
## Memory & Tracking
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**Purpose:** Orchestration hub for the AIPass ecosystem
|
||||
**Module:** `aipass.devpulse`
|
||||
**Status:** Active
|
||||
**Last Updated:** 2026-03-22
|
||||
**Last Updated:** 2026-04-10
|
||||
|
||||
---
|
||||
|
||||
@@ -56,21 +56,25 @@ devpulse/
|
||||
├── .aipass/ # AI context
|
||||
│ └── aipass_local_prompt.md
|
||||
├── .spawn/ # Spawn metadata
|
||||
├── tools/ # Diagnostic scanner suite (20 tools)
|
||||
├── apps/ # Entry point scaffold (minimal — devpulse.py + stubs)
|
||||
├── tools/ # Diagnostic scanner suite (26 tools)
|
||||
├── branch_audits _only/ # Living audit plans for all 11 branches + patrick
|
||||
├── dropbox/ # Incoming files from other branches/users
|
||||
├── docs/ # Tracked documentation
|
||||
├── docs.local/ # Working files (gitignored)
|
||||
├── tests/
|
||||
├── DPLAN-*.md # Active design plans (8-10 at any time)
|
||||
├── CLOSED_PLANS.local.json # Archive of completed plans
|
||||
├── STATUS.local.md # Current work, issues, todos
|
||||
└── README.md
|
||||
```
|
||||
|
||||
DevPulse has no `apps/` directory — it's a **manager** branch, not a builder. It coordinates via dispatch and sub-agents rather than implementing code.
|
||||
DevPulse has a minimal `apps/` scaffold but is primarily a **manager** branch, not a builder. It coordinates via dispatch and sub-agents rather than implementing code.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Tools
|
||||
|
||||
DevPulse maintains a suite of 20 standalone diagnostic scanners in `tools/`. Each follows the `{concern}_scanner_v1.py` naming convention and supports `@branch`, `--all`, and `--summary` flags.
|
||||
DevPulse maintains a suite of 26 standalone diagnostic scanners in `tools/`. Each follows the `{concern}_scanner_v1.py` naming convention and supports `@branch`, `--all`, and `--summary` flags.
|
||||
|
||||
### Code Quality
|
||||
| Tool | What it checks |
|
||||
@@ -104,16 +108,53 @@ DevPulse maintains a suite of 20 standalone diagnostic scanners in `tools/`. Eac
|
||||
| `magic_number_scanner_v1.py` | Hardcoded numbers (cross-file consistency) |
|
||||
| `stale_scanner_v1.py` | Outdated terminology (17 tracked keywords) |
|
||||
|
||||
### Infrastructure
|
||||
| Tool | What it checks |
|
||||
|------|---------------|
|
||||
| `log_scanner_v1.py` | Prax log files for errors/warnings |
|
||||
| `key_exposure_scanner_v1.py` | API key exposure patterns in code |
|
||||
| `meta_header_scanner_v1.py` | Module metadata headers compliance |
|
||||
| `scanner_v1.py` | Base scanner framework |
|
||||
|
||||
### Utilities
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `dev_central_to_devpulse.py` | Rename stale terms across files |
|
||||
| `verify_branch.py` | Verify branch structure compliance |
|
||||
| `git_lock_tool.py` | Manage git lock state for PRs |
|
||||
|
||||
**Tool classification:** Hard checks (pass/fail) vs Advisory (flag for investigation). See DPLAN-0030 for full details.
|
||||
|
||||
---
|
||||
|
||||
## Branch Audits
|
||||
|
||||
DevPulse maintains living audit plans for every branch in `branch_audits _only/`. These are comprehensive health assessments created during adversarial audit sessions (S83 Wave 1-3) and continuously updated.
|
||||
|
||||
Each audit tracks:
|
||||
- **CRITICAL** — Must-fix issues (security, data loss, broken core features)
|
||||
- **BUG** — Functional defects
|
||||
- **STALE/DEAD** — Outdated references, dead code
|
||||
- **QUALITY** — Performance, thread safety, architectural debt
|
||||
- **DOCS** — Documentation gaps and inaccuracies
|
||||
|
||||
| Branch | Audit File | Health |
|
||||
|--------|-----------|--------|
|
||||
| drone | DPLAN-0053 | YELLOW (23 BUG) |
|
||||
| seedgo | DPLAN-0084 | GREEN |
|
||||
| prax | DPLAN-0039 | GREEN |
|
||||
| cli | DPLAN-0074 | YELLOW (2 CRITICAL) |
|
||||
| flow | DPLAN-0082 | YELLOW (5 CRITICAL) |
|
||||
| ai_mail | DPLAN-0036 | GREEN |
|
||||
| api | DPLAN-0029 | YELLOW (3 CRITICAL) |
|
||||
| trigger | DPLAN-0075 | YELLOW (1 CRITICAL) |
|
||||
| spawn | DPLAN-0035 | YELLOW (12 BUG) |
|
||||
| memory | DPLAN-0038 | GREEN |
|
||||
| devpulse | DPLAN-0037 | RED |
|
||||
| patrick | DPLAN-0086 | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
@@ -160,7 +201,9 @@ drone @spawn delete @branch # Archive + deregister branch
|
||||
|
||||
DevPulse is a **manager** branch, not a builder. It delegates code tasks to sub-agents and branch agents. Its context window is reserved for coordination, planning, and architecture — not for reading and editing files across the codebase.
|
||||
|
||||
The `tools/` directory is DevPulse's "tool shed" — standalone diagnostic scripts for investigating code quality across all branches. These tools surface patterns and create conversations. They're built for AI consumption: run a scanner, get instant visibility, decide what matters.
|
||||
The `tools/` directory is DevPulse's "tool shed" — 26 standalone diagnostic scripts for investigating code quality across all branches. These tools surface patterns and create conversations. They're built for AI consumption: run a scanner, get instant visibility, decide what matters.
|
||||
|
||||
The `branch_audits _only/` directory is DevPulse's health dashboard — living audit documents for every branch, updated during adversarial audit sessions and fix sweeps. Each audit tracks CRITICALs, BUGs, quality debt, and documentation gaps with current health ratings.
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -16,7 +16,7 @@ Auto-discovery architecture:
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
from typing import Any
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
@@ -31,7 +31,7 @@ console = Console(stderr=True)
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
def discover_modules() -> list[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
|
||||
@@ -65,7 +65,7 @@ def print_introspection():
|
||||
console.print(f" {name:20} {desc}")
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
for module in modules:
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Plugins
|
||||
|
||||
Scheduled tasks and extensions for `DEVPULSE`.
|
||||
|
||||
Plugins are standalone units of work that can be scheduled. Each plugin handles one specific recurring task.
|
||||
Plugins extend devpulse functionality.
|
||||
|
||||
@@ -104,7 +104,7 @@ def animate_startup():
|
||||
("Connecting AI Mail network", 0.3),
|
||||
("Mounting .trinity/ memories", 0.4),
|
||||
("Starting Prax logging", 0.2),
|
||||
("Seedgo standards: 21 loaded", 0.3),
|
||||
("Seedgo standards: 33 loaded", 0.3),
|
||||
("System ready", 0.1),
|
||||
]
|
||||
|
||||
@@ -199,8 +199,8 @@ def show_stats():
|
||||
"""Final stats box."""
|
||||
stats = [
|
||||
f"{WHITE}Branches: {CYAN}11 registered, 11 operational{RESET}",
|
||||
f"{WHITE}Standards: {GREEN}21 seedgo checks active{RESET}",
|
||||
f"{WHITE}Sessions: {YELLOW}32 completed (devpulse alone){RESET}",
|
||||
f"{WHITE}Standards: {GREEN}33 seedgo checks active{RESET}",
|
||||
f"{WHITE}Sessions: {YELLOW}86 completed (devpulse alone){RESET}",
|
||||
f"{WHITE}Architecture:{MAGENTA} Citizens + Agents + Dispatch{RESET}",
|
||||
f"{WHITE}Built with: {CYAN}Python, Rich, Claude Code{RESET}",
|
||||
f"{WHITE}Memory: {GREEN}.trinity/ — persistent identity{RESET}",
|
||||
|
||||
@@ -107,6 +107,21 @@
|
||||
"file": "apps/plugins/devpulse_ops/fix_plugin.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/hook_sounds/hook_sounds_plugin.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Plugin lives in apps/plugins/ — deliberately outside 3-layer structure (same pattern as devpulse_ops)."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/hook_sounds/hook_sounds_plugin.py",
|
||||
"standard": "naming",
|
||||
"reason": "Plugin filename convention: {plugin_name}_plugin.py inside {plugin_name}/ directory."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/hook_sounds/hook_sounds_plugin.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Simple toggle plugin — creates/removes a flag file. No state changes that warrant trigger events."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -136,10 +136,12 @@ def show_introspection() -> None:
|
||||
# COMMAND HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
def _cwd_has_registry() -> bool:
|
||||
def _cwd_has_registry(max_depth: int = 10) -> bool:
|
||||
"""Check if CWD is within a project that has a *_REGISTRY.json."""
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd] + list(cwd.parents):
|
||||
for i, parent in enumerate([cwd] + list(cwd.parents)):
|
||||
if i >= max_depth:
|
||||
break
|
||||
if list(parent.glob("*_REGISTRY.json")):
|
||||
return True
|
||||
return False
|
||||
@@ -431,6 +433,10 @@ def main() -> int:
|
||||
logger.warning("Registry error during systems listing: %s", exc)
|
||||
err_console.print(f"drone: {exc}")
|
||||
return 1
|
||||
except Exception as exc:
|
||||
logger.error("[drone] Unhandled error in systems: %s", exc)
|
||||
err_console.print(f"drone: unexpected error: {exc}")
|
||||
return 1
|
||||
|
||||
# scan — discover available commands in a branch
|
||||
if command == "scan":
|
||||
|
||||
@@ -18,11 +18,14 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import threading
|
||||
from io import StringIO
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
_capture_lock = threading.Lock()
|
||||
|
||||
|
||||
def capture_main(
|
||||
entry_point_module: str,
|
||||
@@ -54,33 +57,35 @@ def capture_main(
|
||||
argv_parts.append(command)
|
||||
argv_parts.extend(args)
|
||||
|
||||
original_argv = sys.argv
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
captured_out = StringIO()
|
||||
captured_err = StringIO()
|
||||
|
||||
try:
|
||||
sys.argv = argv_parts
|
||||
sys.stdout = captured_out
|
||||
sys.stderr = captured_err
|
||||
with _capture_lock:
|
||||
original_argv = sys.argv
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
|
||||
mod = importlib.import_module(entry_point_module)
|
||||
main_fn = getattr(mod, "main")
|
||||
exit_code = main_fn()
|
||||
except SystemExit as exc:
|
||||
exit_code = exc.code if exc.code is not None else 0
|
||||
logger.info("capture_main: SystemExit(%s) from '%s'", exit_code, entry_point_module)
|
||||
except Exception as exc:
|
||||
captured_err.write(str(exc))
|
||||
exit_code = 1
|
||||
logger.warning(
|
||||
"capture_main: exception from '%s': %s", entry_point_module, exc
|
||||
)
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
try:
|
||||
sys.argv = argv_parts
|
||||
sys.stdout = captured_out
|
||||
sys.stderr = captured_err
|
||||
|
||||
mod = importlib.import_module(entry_point_module)
|
||||
main_fn = getattr(mod, "main")
|
||||
exit_code = main_fn()
|
||||
except SystemExit as exc:
|
||||
exit_code = exc.code if exc.code is not None else 0
|
||||
logger.info("capture_main: SystemExit(%s) from '%s'", exit_code, entry_point_module)
|
||||
except Exception as exc:
|
||||
captured_err.write(str(exc))
|
||||
exit_code = 1
|
||||
logger.warning(
|
||||
"capture_main: exception from '%s': %s", entry_point_module, exc
|
||||
)
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
result = {
|
||||
"stdout": captured_out.getvalue(),
|
||||
|
||||
@@ -82,6 +82,15 @@ def _load_external_modules() -> dict[str, _ExternalModuleConfig]:
|
||||
_EXTERNAL_MODULES: dict[str, _ExternalModuleConfig] = _load_external_modules()
|
||||
|
||||
|
||||
def refresh_external_modules() -> None:
|
||||
"""Reload external module declarations from routing_config.json.
|
||||
|
||||
Call after modifying routing_config.json at runtime.
|
||||
"""
|
||||
global _EXTERNAL_MODULES
|
||||
_EXTERNAL_MODULES = _load_external_modules()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleInfo:
|
||||
"""Metadata about a registered module."""
|
||||
@@ -163,6 +172,9 @@ def route_module_command(
|
||||
mod = importlib.import_module(adapter_path)
|
||||
handler = getattr(mod, "handle_command")
|
||||
result = handler(command, args)
|
||||
# Internal modules may return bool (standard) instead of dict (adapter)
|
||||
if isinstance(result, bool):
|
||||
result = {"stdout": "", "stderr": "", "exit_code": 0 if result else 1}
|
||||
json_handler.log_operation(
|
||||
"route_module_command", {"module": name, "command": command}
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ from aipass.drone.apps.handlers.module_registry_handler import (
|
||||
get_module_help,
|
||||
get_module_introspective,
|
||||
register_module,
|
||||
refresh_external_modules,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -38,6 +39,7 @@ __all__ = [
|
||||
"get_module_help",
|
||||
"get_module_introspective",
|
||||
"register_module",
|
||||
"refresh_external_modules",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,61 @@ from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
|
||||
|
||||
|
||||
def _fix_divergence(repo_root: Path, actions: list[str]) -> None:
|
||||
"""Fetch origin and merge if local main has diverged."""
|
||||
fetch = subprocess.run(
|
||||
["git", "fetch", "origin"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
actions.append(f"Fetch failed: {fetch.stderr.strip()}")
|
||||
logger.error("fix_git_state: fetch failed: %s", fetch.stderr.strip())
|
||||
return
|
||||
|
||||
rev_list = subprocess.run(
|
||||
["git", "rev-list", "--left-right", "--count", "main...origin/main"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if rev_list.returncode != 0:
|
||||
return
|
||||
|
||||
parts = rev_list.stdout.strip().split()
|
||||
ahead = int(parts[0]) if len(parts) >= 1 else 0
|
||||
behind = int(parts[1]) if len(parts) >= 2 else 0
|
||||
if ahead == 0 or behind == 0:
|
||||
return
|
||||
|
||||
merge = subprocess.run(
|
||||
["git", "merge", "origin/main", "--no-edit"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if merge.returncode == 0:
|
||||
actions.append(f"Merged origin/main (was ahead={ahead}, behind={behind})")
|
||||
logger.info("fix_git_state: merged origin/main ahead=%d behind=%d", ahead, behind)
|
||||
return
|
||||
|
||||
# Merge failed — report conflict files and abort
|
||||
diff = subprocess.run(
|
||||
["git", "diff", "--name-only", "--diff-filter=U"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else []
|
||||
subprocess.run(
|
||||
["git", "merge", "--abort"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if conflict_files:
|
||||
actions.append(
|
||||
f"Merge conflict (ahead={ahead}, behind={behind}). "
|
||||
f"Conflicting files: {', '.join(conflict_files)}"
|
||||
)
|
||||
else:
|
||||
actions.append(
|
||||
f"Merge failed (ahead={ahead}, behind={behind}): {merge.stderr.strip()}"
|
||||
)
|
||||
logger.warning("fix_git_state: merge conflict ahead=%d behind=%d", ahead, behind)
|
||||
|
||||
|
||||
def fix_git_state(caller: str) -> dict:
|
||||
"""Detect and fix common broken git states.
|
||||
|
||||
@@ -78,55 +133,7 @@ def fix_git_state(caller: str) -> dict:
|
||||
logger.error("fix_git_state: checkout main failed: %s", checkout.stderr.strip())
|
||||
|
||||
# Check 3: Diverged from origin — fetch + merge
|
||||
fetch = subprocess.run(
|
||||
["git", "fetch", "origin"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
actions.append(f"Fetch failed: {fetch.stderr.strip()}")
|
||||
logger.error("fix_git_state: fetch failed: %s", fetch.stderr.strip())
|
||||
else:
|
||||
rev_list = subprocess.run(
|
||||
["git", "rev-list", "--left-right", "--count", "main...origin/main"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if rev_list.returncode == 0:
|
||||
parts = rev_list.stdout.strip().split()
|
||||
ahead = int(parts[0]) if len(parts) >= 1 else 0
|
||||
behind = int(parts[1]) if len(parts) >= 2 else 0
|
||||
if ahead > 0 and behind > 0:
|
||||
merge = subprocess.run(
|
||||
["git", "merge", "origin/main", "--no-edit"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if merge.returncode == 0:
|
||||
actions.append(
|
||||
f"Merged origin/main (was ahead={ahead}, behind={behind})"
|
||||
)
|
||||
logger.info("fix_git_state: merged origin/main ahead=%d behind=%d", ahead, behind)
|
||||
else:
|
||||
# Report conflict files
|
||||
diff = subprocess.run(
|
||||
["git", "diff", "--name-only", "--diff-filter=U"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else []
|
||||
# Abort the failed merge
|
||||
subprocess.run(
|
||||
["git", "merge", "--abort"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if conflict_files:
|
||||
actions.append(
|
||||
f"Merge conflict (ahead={ahead}, behind={behind}). "
|
||||
f"Conflicting files: {', '.join(conflict_files)}"
|
||||
)
|
||||
else:
|
||||
actions.append(
|
||||
f"Merge failed (ahead={ahead}, behind={behind}): "
|
||||
f"{merge.stderr.strip()}"
|
||||
)
|
||||
logger.warning("fix_git_state: merge conflict ahead=%d behind=%d", ahead, behind)
|
||||
_fix_divergence(repo_root, actions)
|
||||
|
||||
# Check 4: Dirty index with no intent
|
||||
cached = subprocess.run(
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
MUTE_FLAG = Path("/tmp/aipass-hooks-muted")
|
||||
@@ -56,19 +57,19 @@ def handle_command(command: str | None = None, args: list[str] | None = None) ->
|
||||
|
||||
if command == "off":
|
||||
mute()
|
||||
print("Hook sounds: MUTED")
|
||||
console.print("Hook sounds: MUTED")
|
||||
return True
|
||||
|
||||
if command == "on":
|
||||
unmute()
|
||||
print("Hook sounds: ACTIVE")
|
||||
console.print("Hook sounds: ACTIVE")
|
||||
return True
|
||||
|
||||
# No command = show status
|
||||
if is_muted():
|
||||
print("Hook sounds: MUTED (off)")
|
||||
print(f" Flag: {MUTE_FLAG}")
|
||||
console.print("Hook sounds: MUTED (off)")
|
||||
console.print(f" Flag: {MUTE_FLAG}")
|
||||
else:
|
||||
print("Hook sounds: ACTIVE (on)")
|
||||
console.print("Hook sounds: ACTIVE (on)")
|
||||
|
||||
return True
|
||||
|
||||
@@ -108,6 +108,16 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands or shows help"""
|
||||
try:
|
||||
return _main_impl()
|
||||
except Exception as exc:
|
||||
logger.error("[flow] Unhandled error in main: %s", exc)
|
||||
error(f"Unexpected error: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
def _main_impl():
|
||||
"""Internal implementation of main — separated for catch-all handler."""
|
||||
|
||||
# Discover available modules
|
||||
modules = discover_modules()
|
||||
|
||||
@@ -54,8 +54,9 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
# Registry location
|
||||
REGISTRY_FILE = FLOW_ROOT / "flow_json" / "fplan_registry.json"
|
||||
# Registry location (fallback default)
|
||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
||||
|
||||
# Dashboard template path (package-relative)
|
||||
DASHBOARD_TEMPLATE_FILE = _PKG_ROOT / "devpulse" / "templates" / "DASHBOARD.template.json"
|
||||
@@ -194,25 +195,56 @@ def _calculate_quick_status(sections: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# =============================================
|
||||
# PLAN TYPE HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _get_all_registry_files() -> List[str]:
|
||||
"""Return per-type registry filenames via plan-type discovery."""
|
||||
try:
|
||||
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
|
||||
|
||||
files: List[str] = []
|
||||
for _key, config in discover_plan_types().items():
|
||||
rf = config.get("registry_file")
|
||||
if rf and rf not in files:
|
||||
files.append(rf)
|
||||
if files:
|
||||
return files
|
||||
except Exception as exc:
|
||||
logger.warning("[push_branch_dashboard] Failed to discover plan types, falling back to default registry: %s", exc)
|
||||
return [REGISTRY_FILE.name]
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def _load_registry() -> Dict[str, Any]:
|
||||
"""
|
||||
Load fplan_registry.json.
|
||||
Load all per-type plan registries and merge into a single dict.
|
||||
|
||||
Returns:
|
||||
Registry dict or empty structure if unavailable
|
||||
Merged registry dict or empty structure if unavailable
|
||||
"""
|
||||
try:
|
||||
if not REGISTRY_FILE.exists():
|
||||
return {"plans": {}, "next_number": 1}
|
||||
with open(REGISTRY_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load fplan registry '%s': %s", REGISTRY_FILE, exc)
|
||||
return {"plans": {}, "next_number": 1}
|
||||
merged: Dict[str, Any] = {"plans": {}, "next_number": 1}
|
||||
for registry_file in _get_all_registry_files():
|
||||
target = FLOW_JSON_DIR / registry_file
|
||||
try:
|
||||
if not target.exists():
|
||||
continue
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for plan_num, plan_data in data.get("plans", {}).items():
|
||||
merged["plans"][plan_num] = plan_data
|
||||
# Keep the highest next_number across registries
|
||||
nn = data.get("next_number", 1)
|
||||
if nn > merged["next_number"]:
|
||||
merged["next_number"] = nn
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load registry '%s': %s", target, exc)
|
||||
return merged
|
||||
|
||||
|
||||
def _filter_branch_plans(
|
||||
|
||||
@@ -49,6 +49,8 @@ from aipass.flow.apps.handlers.plan.aggregate_ops import aggregate_central_impl
|
||||
MODULE_NAME = "push_central"
|
||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
@@ -62,25 +64,54 @@ _REPO_ROOT = _find_repo_root()
|
||||
AI_CENTRAL_DIR = _REPO_ROOT / ".ai_central"
|
||||
CENTRAL_FILE = AI_CENTRAL_DIR / "PLANS.central.json"
|
||||
|
||||
# =============================================
|
||||
# PLAN TYPE HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _get_all_registry_files() -> List[str]:
|
||||
"""Return per-type registry filenames via plan-type discovery."""
|
||||
try:
|
||||
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
|
||||
|
||||
files: List[str] = []
|
||||
for _key, config in discover_plan_types().items():
|
||||
rf = config.get("registry_file")
|
||||
if rf and rf not in files:
|
||||
files.append(rf)
|
||||
if files:
|
||||
return files
|
||||
except Exception as exc:
|
||||
logger.warning("[push_central] Failed to discover plan types, falling back to default registry: %s", exc)
|
||||
return [REGISTRY_FILE.name]
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def _load_registry() -> Dict[str, Any]:
|
||||
"""Load fplan_registry.json
|
||||
"""Load all per-type plan registries and merge into a single dict.
|
||||
|
||||
Returns:
|
||||
Registry dict or empty structure if file doesn't exist
|
||||
Merged registry dict or empty structure if no files found
|
||||
"""
|
||||
if not REGISTRY_FILE.exists():
|
||||
return {"plans": {}, "next_number": 1}
|
||||
|
||||
try:
|
||||
with open(REGISTRY_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load fplan registry '%s': %s", REGISTRY_FILE, exc)
|
||||
return {"plans": {}, "next_number": 1}
|
||||
merged: Dict[str, Any] = {"plans": {}, "next_number": 1}
|
||||
for registry_file in _get_all_registry_files():
|
||||
target = FLOW_JSON_DIR / registry_file
|
||||
try:
|
||||
if not target.exists():
|
||||
continue
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for plan_num, plan_data in data.get("plans", {}).items():
|
||||
merged["plans"][plan_num] = plan_data
|
||||
nn = data.get("next_number", 1)
|
||||
if nn > merged["next_number"]:
|
||||
merged["next_number"] = nn
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load registry '%s': %s", target, exc)
|
||||
return merged
|
||||
|
||||
|
||||
def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict]]:
|
||||
|
||||
@@ -82,28 +82,64 @@ FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
# CONFIGURATION
|
||||
# =============================================
|
||||
|
||||
REGISTRY_FILE = FLOW_ROOT / "flow_json" / "fplan_registry.json"
|
||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
||||
DASHBOARD_FILE = FLOW_ROOT / "DASHBOARD.local.json"
|
||||
|
||||
|
||||
# =============================================
|
||||
# PLAN TYPE HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _get_all_registry_files() -> List[str]:
|
||||
"""Return per-type registry filenames via plan-type discovery."""
|
||||
try:
|
||||
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
|
||||
|
||||
files: List[str] = []
|
||||
for _key, config in discover_plan_types().items():
|
||||
rf = config.get("registry_file")
|
||||
if rf and rf not in files:
|
||||
files.append(rf)
|
||||
if files:
|
||||
return files
|
||||
except Exception as exc:
|
||||
logger.warning("[update_local] Failed to discover plan types, falling back to default registry: %s", exc)
|
||||
return [REGISTRY_FILE.name]
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def _read_registry() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Read fplan_registry.json.
|
||||
Read all per-type plan registries and merge into a single dict.
|
||||
|
||||
Returns:
|
||||
Registry dict or None if error
|
||||
Merged registry dict or None if no registries found
|
||||
"""
|
||||
try:
|
||||
if not REGISTRY_FILE.exists():
|
||||
return None
|
||||
with open(REGISTRY_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read fplan registry '%s': %s", REGISTRY_FILE, exc)
|
||||
merged: Dict[str, Any] = {"plans": {}, "next_number": 1}
|
||||
found_any = False
|
||||
for registry_file in _get_all_registry_files():
|
||||
target = FLOW_JSON_DIR / registry_file
|
||||
try:
|
||||
if not target.exists():
|
||||
continue
|
||||
with open(target, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
found_any = True
|
||||
for plan_num, plan_data in data.get("plans", {}).items():
|
||||
merged["plans"][plan_num] = plan_data
|
||||
nn = data.get("next_number", 1)
|
||||
if nn > merged["next_number"]:
|
||||
merged["next_number"] = nn
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read registry '%s': %s", target, exc)
|
||||
if not found_any:
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
|
||||
@@ -110,17 +110,17 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def extract_plan_number(plan_id: str) -> Optional[str]:
|
||||
"""Extract plan number from plan_id (e.g., 'FPLAN-0148' -> '0148')
|
||||
"""Extract plan number from plan_id (e.g., 'FPLAN-0148' -> '0148', 'DPLAN-0004' -> '0004')
|
||||
|
||||
Args:
|
||||
plan_id: Plan ID string (e.g., 'FPLAN-0148')
|
||||
plan_id: Plan ID string with any prefix (e.g., 'FPLAN-0148', 'DPLAN-0004')
|
||||
|
||||
Returns:
|
||||
Plan number string or None if invalid format
|
||||
"""
|
||||
if not plan_id or not plan_id.startswith("FPLAN-"):
|
||||
if not plan_id or '-' not in plan_id:
|
||||
return None
|
||||
return plan_id[6:] # Skip 'FPLAN-' prefix
|
||||
return plan_id.split('-', 1)[1]
|
||||
|
||||
|
||||
def auto_close_plan(registry_path: Path, plan_id: str, branch_name: str) -> bool:
|
||||
|
||||
@@ -14,6 +14,7 @@ Creates the file if it doesn't exist.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
@@ -40,7 +41,12 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path)
|
||||
True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
plan_id = f"FPLAN-{plan_key}"
|
||||
# Extract prefix from plan_info's file_path (e.g., FPLAN, DPLAN)
|
||||
file_path = plan_info.get("file_path", "")
|
||||
filename = Path(file_path).name if file_path else ""
|
||||
prefix_match = re.match(r'^([A-Z]+PLAN)', filename)
|
||||
prefix = prefix_match.group(1) if prefix_match else "FPLAN"
|
||||
plan_id = f"{prefix}-{plan_key}"
|
||||
|
||||
# Extract date (YYYY-MM-DD) from the closed ISO timestamp
|
||||
closed_raw = plan_info.get("closed", "")
|
||||
@@ -49,7 +55,7 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path)
|
||||
# Build the entry
|
||||
entry = {
|
||||
"plan_id": plan_id,
|
||||
"type": "FPLAN",
|
||||
"type": prefix,
|
||||
"subject": plan_info.get("subject", ""),
|
||||
"date_closed": date_closed,
|
||||
"location": plan_info.get("relative_path", "")
|
||||
|
||||
@@ -19,6 +19,8 @@ Usage:
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
@@ -26,13 +28,41 @@ _PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||
from aipass.flow.apps.handlers.registry.load_registry import load_registry
|
||||
from aipass.flow.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================
|
||||
# MULTI-REGISTRY DISCOVERY
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "get_closed_plans"
|
||||
|
||||
|
||||
def _get_all_registry_files() -> List[str]:
|
||||
"""Return per-type registry filenames via plan-type discovery.
|
||||
|
||||
Uses the same pattern as mbank/process.py to discover all plan-type
|
||||
registries (e.g. fplan_registry.json, dplan_registry.json).
|
||||
Falls back to the default fplan_registry.json if discovery fails.
|
||||
"""
|
||||
try:
|
||||
from aipass.flow.apps.handlers.template.plan_type_loader import discover_plan_types
|
||||
files: List[str] = []
|
||||
for _key, config in discover_plan_types().items():
|
||||
rf = config.get("registry_file")
|
||||
if rf and rf not in files:
|
||||
files.append(rf)
|
||||
if files:
|
||||
return files
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] Failed to discover plan types, falling back to default registry: %s", MODULE_NAME, exc)
|
||||
return ["fplan_registry.json"]
|
||||
|
||||
|
||||
# =============================================
|
||||
# HANDLER FUNCTION
|
||||
# =============================================
|
||||
|
||||
def get_closed_plans() -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""
|
||||
Get all closed plans from registry
|
||||
Get all closed plans from ALL discovered registries
|
||||
|
||||
Returns:
|
||||
List of tuples: [(plan_num, plan_info), ...]
|
||||
@@ -43,15 +73,18 @@ def get_closed_plans() -> List[Tuple[str, Dict[str, Any]]]:
|
||||
>>> for plan_num, plan_info in plans:
|
||||
... print(f"PLAN{plan_num}: {plan_info['subject']}")
|
||||
"""
|
||||
# Load registry
|
||||
registry = load_registry()
|
||||
closed_plans: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
# Filter for closed plans
|
||||
closed_plans = [
|
||||
(plan_num, plan_info)
|
||||
for plan_num, plan_info in registry.get("plans", {}).items()
|
||||
if plan_info.get("status") == "closed"
|
||||
]
|
||||
for reg_file in _get_all_registry_files():
|
||||
try:
|
||||
registry = load_registry(registry_file=reg_file)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] Failed to load registry '%s': %s", MODULE_NAME, reg_file, exc)
|
||||
continue
|
||||
|
||||
for plan_num, plan_info in registry.get("plans", {}).items():
|
||||
if plan_info.get("status") == "closed":
|
||||
closed_plans.append((plan_num, plan_info))
|
||||
|
||||
json_handler.log_operation("closed_plans_retrieved", {"count": len(closed_plans)})
|
||||
return closed_plans
|
||||
|
||||
@@ -71,12 +71,12 @@ def recover_plan_from_backup(plan_key: str, load_registry: Any = None, save_regi
|
||||
|
||||
# Search for any prefix matching the plan key (FPLAN-, DPLAN-, etc.)
|
||||
variants = list(processed_plans.glob(f"*-{plan_key}*.md")) if processed_plans.exists() else []
|
||||
plan_file = processed_plans / f"FPLAN-{plan_key}.md" # fallback default
|
||||
plan_file = None # No default -- use variant search
|
||||
if variants:
|
||||
# Sort by modification time, newest first
|
||||
variants.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
plan_file = variants[0] # Use most recent backup
|
||||
elif not plan_file.exists():
|
||||
if plan_file is None or not plan_file.exists():
|
||||
return False, f"Plan {plan_key} not found in backups"
|
||||
|
||||
# Read plan file to extract original location from header
|
||||
@@ -209,7 +209,7 @@ def restore_plan_impl(
|
||||
exists, error_msg = validate_plan_exists(plan_key, registry)
|
||||
if not exists:
|
||||
# AUTO-RECOVERY: Try to recover from processed_plans
|
||||
messages.append({"type": "warning", "text": f"FPLAN-{plan_key} not in registry - attempting recovery..."})
|
||||
messages.append({"type": "warning", "text": f"PLAN-{plan_key} not in registry - attempting recovery..."})
|
||||
recovered, recovery_msg = recover_plan_from_backup_fn(plan_key)
|
||||
|
||||
if recovered:
|
||||
@@ -234,7 +234,7 @@ def restore_plan_impl(
|
||||
|
||||
# 4. VALIDATE: Check plan is closed
|
||||
if plan_info.get("status") != "closed":
|
||||
logger.warning(f"[{MODULE_NAME}] FPLAN-{plan_key} is already open")
|
||||
logger.warning(f"[{MODULE_NAME}] Plan {plan_key} is already open")
|
||||
messages.append({"type": "error", "error_type": "already_open", "plan_key": plan_key})
|
||||
return {
|
||||
"success": False,
|
||||
@@ -268,7 +268,7 @@ def restore_plan_impl(
|
||||
plan_info.pop('memory_file', None)
|
||||
|
||||
save_registry(registry)
|
||||
logger.info(f"[{MODULE_NAME}] Restored FPLAN-{plan_key} to open status")
|
||||
logger.info(f"[{MODULE_NAME}] Restored plan {plan_key} to open status")
|
||||
|
||||
# 8. UPDATE DASHBOARDS: Sync dashboard files (handlers)
|
||||
dashboard_success = update_dashboard_local()
|
||||
|
||||
@@ -147,94 +147,98 @@ def load_registry() -> Dict[str, Any]:
|
||||
"type_count": len(data["types"]),
|
||||
}
|
||||
|
||||
# Auto-heal: prune orphaned types (directory deleted but registry entry remains)
|
||||
templates_dir = FLOW_ROOT / "templates"
|
||||
orphaned = [
|
||||
dir_name
|
||||
for dir_name in data["types"]
|
||||
if dir_name not in _PROTECTED_TYPES
|
||||
and not (templates_dir / dir_name).is_dir()
|
||||
]
|
||||
if orphaned:
|
||||
plan_registry_dir = FLOW_ROOT / "flow_json"
|
||||
for dir_name in orphaned:
|
||||
entry = data["types"][dir_name]
|
||||
shorthand = entry.get("shorthand", entry.get("prefix", "").lower())
|
||||
logger.info(
|
||||
"[%s] Auto-pruning orphaned type '%s' (directory missing)",
|
||||
MODULE_NAME,
|
||||
dir_name,
|
||||
)
|
||||
del data["types"][dir_name]
|
||||
|
||||
# Clean up the per-type plan registry JSON
|
||||
if shorthand:
|
||||
plan_reg = plan_registry_dir / f"{shorthand}_registry.json"
|
||||
if plan_reg.exists():
|
||||
plan_reg.unlink()
|
||||
logger.info(
|
||||
"[%s] Removed orphaned plan registry: %s",
|
||||
MODULE_NAME,
|
||||
plan_reg.name,
|
||||
)
|
||||
# Auto-heal: prune orphans + register new dirs
|
||||
if _prune_orphaned_types(data):
|
||||
save_registry(data)
|
||||
if _auto_register_new_types(data):
|
||||
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
|
||||
|
||||
|
||||
def _prune_orphaned_types(data: Dict[str, Any]) -> bool:
|
||||
"""Remove registry entries whose template directory no longer exists.
|
||||
|
||||
Returns True if any entries were pruned.
|
||||
"""
|
||||
templates_dir = FLOW_ROOT / "templates"
|
||||
orphaned = [
|
||||
d for d in data["types"]
|
||||
if d not in _PROTECTED_TYPES and not (templates_dir / d).is_dir()
|
||||
]
|
||||
if not orphaned:
|
||||
return False
|
||||
|
||||
plan_registry_dir = FLOW_ROOT / "flow_json"
|
||||
for dir_name in orphaned:
|
||||
entry = data["types"][dir_name]
|
||||
shorthand = entry.get("shorthand", entry.get("prefix", "").lower())
|
||||
logger.info("[%s] Auto-pruning orphaned type '%s' (directory missing)", MODULE_NAME, dir_name)
|
||||
del data["types"][dir_name]
|
||||
if shorthand:
|
||||
plan_reg = plan_registry_dir / f"{shorthand}_registry.json"
|
||||
if plan_reg.exists():
|
||||
plan_reg.unlink()
|
||||
logger.info("[%s] Removed orphaned plan registry: %s", MODULE_NAME, plan_reg.name)
|
||||
return True
|
||||
|
||||
|
||||
def _auto_register_new_types(data: Dict[str, Any]) -> bool:
|
||||
"""Detect unregistered template directories and register them.
|
||||
|
||||
Returns True if any new types were registered.
|
||||
"""
|
||||
templates_dir = FLOW_ROOT / "templates"
|
||||
if not templates_dir.is_dir():
|
||||
return False
|
||||
|
||||
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 or not list(child.glob("*.md")):
|
||||
continue
|
||||
prefix = _derive_prefix(child.name, used_prefixes)
|
||||
if prefix is None:
|
||||
continue # Collision — 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_plan_registry(shorthand)
|
||||
return changed
|
||||
|
||||
|
||||
def _derive_prefix(dir_name: str, used: set) -> str | None:
|
||||
"""Derive a unique prefix from a directory name, or None if collision."""
|
||||
first_word = dir_name.split("_")[0]
|
||||
prefix = (first_word[0].upper() + "PLAN") if first_word else "XPLAN"
|
||||
if prefix in used and len(first_word) > 1:
|
||||
prefix = first_word[:2].upper() + "PLAN"
|
||||
return None if prefix in used else prefix
|
||||
|
||||
|
||||
def _create_plan_registry(shorthand: str) -> None:
|
||||
"""Create an empty plan registry JSON for a new type."""
|
||||
plan_reg = FLOW_ROOT / "flow_json" / f"{shorthand}_registry.json"
|
||||
if plan_reg.exists():
|
||||
return
|
||||
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, shorthand, exc)
|
||||
|
||||
|
||||
def save_registry(data: Dict[str, Any]) -> bool:
|
||||
"""Save the template registry to disk.
|
||||
|
||||
|
||||
@@ -92,18 +92,53 @@ def handle_command(command: str, args: list) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _try_create_lock() -> bool:
|
||||
"""Atomically create lock file with current PID. Returns True on success."""
|
||||
try:
|
||||
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
logger.info("[%s] Lock file already exists, cannot acquire", MODULE_NAME)
|
||||
return False
|
||||
|
||||
|
||||
def _is_lock_stale() -> bool:
|
||||
"""Check if existing lock file belongs to a dead process."""
|
||||
try:
|
||||
pid = int(LOCK_FILE.read_text(encoding="utf-8").strip())
|
||||
os.kill(pid, 0)
|
||||
logger.info("[%s] Another instance running (PID %d), exiting", MODULE_NAME, pid)
|
||||
return False # Process alive — lock is valid
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
logger.info("[%s] Stale lock found, taking over", MODULE_NAME)
|
||||
return True
|
||||
|
||||
|
||||
def _acquire_lock() -> bool:
|
||||
"""Try to acquire lock file. Returns True if acquired, False if another instance is running."""
|
||||
if LOCK_FILE.exists():
|
||||
try:
|
||||
pid = int(LOCK_FILE.read_text().strip())
|
||||
os.kill(pid, 0) # Signal 0 = check if process exists
|
||||
logger.info(f"[{MODULE_NAME}] Another instance running (PID {pid}), exiting")
|
||||
return False
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
logger.info(f"[{MODULE_NAME}] Stale lock found, taking over")
|
||||
"""Try to acquire lock file. Returns True if acquired, False if another instance is running.
|
||||
|
||||
Uses atomic O_CREAT | O_EXCL to avoid TOCTOU race between existence check and write.
|
||||
"""
|
||||
if _try_create_lock():
|
||||
return True
|
||||
|
||||
# Lock exists — check if stale
|
||||
if not _is_lock_stale():
|
||||
return False
|
||||
|
||||
# Remove stale lock and retry
|
||||
try:
|
||||
LOCK_FILE.unlink()
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Failed to remove stale lock: %s", MODULE_NAME, exc)
|
||||
return False
|
||||
|
||||
if not _try_create_lock():
|
||||
logger.info("[%s] Another process grabbed lock during retry", MODULE_NAME)
|
||||
return False
|
||||
|
||||
LOCK_FILE.write_text(str(os.getpid()))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -152,9 +152,10 @@ class TestExtractPlanNumber:
|
||||
extract_plan_number = _import("extract_plan_number")
|
||||
assert extract_plan_number(None) is None
|
||||
|
||||
def test_wrong_prefix(self):
|
||||
def test_other_prefix(self):
|
||||
"""Any PREFIX-number pattern is accepted (DPLAN, FPLAN, etc.)"""
|
||||
extract_plan_number = _import("extract_plan_number")
|
||||
assert extract_plan_number("DPLAN-0001") is None
|
||||
assert extract_plan_number("DPLAN-0001") == "0001"
|
||||
|
||||
def test_no_dash(self):
|
||||
extract_plan_number = _import("extract_plan_number")
|
||||
|
||||
@@ -393,12 +393,14 @@ class TestAutoCloseOrphanedPlans:
|
||||
class TestGetClosedPlans:
|
||||
"""Tests for get_closed_plans()."""
|
||||
|
||||
_SINGLE_REG = ["fplan_registry.json"]
|
||||
_DISCOVERY_PATH = "aipass.flow.apps.handlers.plan.get_closed_plans._get_all_registry_files"
|
||||
_LOAD_PATH = "aipass.flow.apps.handlers.plan.get_closed_plans.load_registry"
|
||||
|
||||
def test_returns_only_closed_plans(self, mock_registry):
|
||||
_, registry = mock_registry
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
|
||||
patch(self._LOAD_PATH, return_value=registry):
|
||||
result = get_closed_plans()
|
||||
|
||||
assert len(result) == 1
|
||||
@@ -412,19 +414,15 @@ class TestGetClosedPlans:
|
||||
"1": {"status": "open", "subject": "active"},
|
||||
}
|
||||
}
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
|
||||
patch(self._LOAD_PATH, return_value=registry):
|
||||
result = get_closed_plans()
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_returns_empty_on_empty_registry(self):
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
|
||||
return_value={"plans": {}},
|
||||
):
|
||||
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
|
||||
patch(self._LOAD_PATH, return_value={"plans": {}}):
|
||||
result = get_closed_plans()
|
||||
|
||||
assert result == []
|
||||
@@ -437,10 +435,8 @@ class TestGetClosedPlans:
|
||||
"3": {"status": "open", "subject": "still going"},
|
||||
}
|
||||
}
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
|
||||
patch(self._LOAD_PATH, return_value=registry):
|
||||
result = get_closed_plans()
|
||||
|
||||
assert len(result) == 2
|
||||
@@ -449,10 +445,8 @@ class TestGetClosedPlans:
|
||||
|
||||
def test_result_tuples_contain_plan_num_and_info(self, mock_registry):
|
||||
_, registry = mock_registry
|
||||
with patch(
|
||||
"aipass.flow.apps.handlers.plan.get_closed_plans.load_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
with patch(self._DISCOVERY_PATH, return_value=self._SINGLE_REG), \
|
||||
patch(self._LOAD_PATH, return_value=registry):
|
||||
result = get_closed_plans()
|
||||
|
||||
for plan_num, plan_info in result:
|
||||
|
||||
@@ -491,6 +491,26 @@
|
||||
"standard": "deep_nesting",
|
||||
"reason": "Vector search with nested result filtering and similarity scoring."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/symbolic/deduplicator.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Cross-branch service call to api branch key management. api has no modules-level get_api_key() for external callers."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/symbolic/extractor.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Cross-branch service call to api branch key management. api has no modules-level get_api_key() for external callers."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/symbolic/deduplicator.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Cross-branch handler import for api key retrieval — api branch's canonical key interface is at handler level."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/symbolic/extractor.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Cross-branch handler import for api key retrieval — api branch's canonical key interface is at handler level."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/symbolic/deduplicator.py",
|
||||
"standard": "deep_nesting",
|
||||
@@ -519,7 +539,7 @@
|
||||
{
|
||||
"file": "apps/handlers/learnings/manager.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [764],
|
||||
"lines": [773],
|
||||
"reason": "Public API for learning extraction pipeline — called dynamically by symbolic extraction."
|
||||
},
|
||||
{
|
||||
@@ -543,7 +563,7 @@
|
||||
{
|
||||
"file": "apps/handlers/symbolic/extractor.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [437],
|
||||
"lines": [427],
|
||||
"reason": "LLM-based extraction function — called conditionally when API key is available."
|
||||
}
|
||||
],
|
||||
|
||||
@@ -113,7 +113,8 @@ def deduplicate_fragment(
|
||||
try:
|
||||
from aipass.api.apps.handlers.auth.keys import get_api_key
|
||||
api_key = get_api_key("openrouter")
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.warning(f"[deduplicator] api branch not available for key loading: {e}")
|
||||
api_key = None
|
||||
|
||||
if not api_key:
|
||||
|
||||
@@ -356,7 +356,8 @@ def extract_fragments_llm(chat_history: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
try:
|
||||
from aipass.api.apps.handlers.auth.keys import get_api_key
|
||||
api_key = get_api_key("openrouter")
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.warning(f"[extractor] api branch not available for key loading: {e}")
|
||||
api_key = None
|
||||
|
||||
if not api_key:
|
||||
|
||||
@@ -55,6 +55,13 @@
|
||||
"subject": "DPLAN-0039 prax audit work items — test coverage, JSON corruption, stale cleanup",
|
||||
"date_closed": "2026-03-29",
|
||||
"location": "prax"
|
||||
},
|
||||
{
|
||||
"plan_id": "FPLAN-0097",
|
||||
"type": "FPLAN",
|
||||
"subject": "Codex + Gemini log monitoring adapters for prax monitor",
|
||||
"date_closed": "2026-04-10",
|
||||
"location": "prax"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ def detect_branch_from_path(module_path: str) -> Optional[str]:
|
||||
# relative is like: flow/apps/module.py → parts[0] = "flow"
|
||||
if len(relative.parts) >= 2:
|
||||
branch = relative.parts[0]
|
||||
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch})
|
||||
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch}, module_name="prax_introspection")
|
||||
return branch
|
||||
except ValueError:
|
||||
logger.info("Path %s is not relative to aipass package root", module_path)
|
||||
@@ -134,7 +134,7 @@ def detect_branch_from_path(module_path: str) -> Optional[str]:
|
||||
relative = path.relative_to(_SRC_ROOT)
|
||||
if len(relative.parts) >= 2 and relative.parts[0] != "aipass":
|
||||
branch = relative.parts[0]
|
||||
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch, "outside_aipass": True})
|
||||
json_handler.log_operation("introspection_resolved", {"module_path": module_path, "branch": branch, "outside_aipass": True}, module_name="prax_introspection")
|
||||
return branch
|
||||
except ValueError:
|
||||
logger.info("Path %s is not relative to src root", module_path)
|
||||
|
||||
@@ -225,4 +225,8 @@ Examples:
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as exc:
|
||||
logger.error("Unhandled error in main: %s", exc)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -226,32 +226,36 @@ def print_help() -> None:
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point - routes to modules."""
|
||||
modules = discover_modules()
|
||||
args = sys.argv[1:]
|
||||
try:
|
||||
modules = discover_modules()
|
||||
args = sys.argv[1:]
|
||||
|
||||
# No args → introspection (discovery mode)
|
||||
if not args:
|
||||
print_introspection()
|
||||
return 0
|
||||
# No args → introspection (discovery mode)
|
||||
if not args:
|
||||
print_introspection()
|
||||
return 0
|
||||
|
||||
# Help flag → full help with usage
|
||||
if args[0] in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
return 0
|
||||
# Help flag → full help with usage
|
||||
if args[0] in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
if args[0] in ["--version", "-V"]:
|
||||
console.print(f"seedgo v{VERSION}")
|
||||
return 0
|
||||
if args[0] in ["--version", "-V"]:
|
||||
console.print(f"seedgo v{VERSION}")
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Route to modules
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
# Route to modules
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
error(f"Unknown command: {command}", suggestion="Run 'seedgo --help' for usage")
|
||||
return 1
|
||||
error(f"Unknown command: {command}", suggestion="Run 'seedgo --help' for usage")
|
||||
return 1
|
||||
except Exception as exc:
|
||||
logger.error('[seedgo] Unhandled error in main: %s', exc)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -26,9 +26,6 @@ from aipass.spawn.apps.handlers.registry import (
|
||||
)
|
||||
from aipass.spawn.apps.handlers.json import json_handler
|
||||
|
||||
# Repo root — resolved from spawn package location
|
||||
_REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass
|
||||
|
||||
# Branches that cannot be deleted (critical infrastructure)
|
||||
_PROTECTED_BRANCHES = {"spawn", "devpulse", "drone"}
|
||||
|
||||
@@ -37,6 +34,81 @@ _PROTECTED_BRANCHES = {"spawn", "devpulse", "drone"}
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
def _resolve_branch_dir(branch_name, registry_path, registry):
|
||||
"""Find branch entry and directory from registry.
|
||||
|
||||
Returns:
|
||||
Tuple of (branch_entry, branch_dir) or (None, None) if not found.
|
||||
"""
|
||||
project_root = registry_path.parent
|
||||
for entry in _branches_as_list(registry.get("branches", [])):
|
||||
if entry.get("name", "").lower() == branch_name.lower():
|
||||
rel_path = entry.get("path", "")
|
||||
branch_dir = (project_root / rel_path).resolve() if rel_path else None
|
||||
return entry, branch_dir
|
||||
return None, None
|
||||
|
||||
|
||||
def _archive_branch(branch_dir, archive_dir):
|
||||
"""Copy branch to archive directory. Cleans up on failure.
|
||||
|
||||
Returns:
|
||||
Error message string, or None on success.
|
||||
"""
|
||||
archive_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copytree(str(branch_dir), str(archive_dir))
|
||||
logger.info(f"[delete] Archived to {archive_dir}")
|
||||
return None
|
||||
except Exception as exc:
|
||||
msg = f"Failed to create archive: {exc}"
|
||||
logger.error(f"[delete] {msg}")
|
||||
_cleanup_partial_archive(archive_dir)
|
||||
return msg
|
||||
|
||||
|
||||
def _cleanup_partial_archive(archive_dir):
|
||||
"""Remove partially-created archive directory."""
|
||||
if not archive_dir.exists():
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(str(archive_dir))
|
||||
except Exception as cleanup_exc:
|
||||
logger.warning("[delete] Failed to clean up partial archive %s: %s", archive_dir, cleanup_exc)
|
||||
|
||||
|
||||
def _remove_from_registry(registry, branch_name, registry_path):
|
||||
"""Remove branch from registry and save.
|
||||
|
||||
Returns:
|
||||
True if registry was updated successfully.
|
||||
"""
|
||||
branches = registry.get("branches", [])
|
||||
if isinstance(branches, dict):
|
||||
for key in list(branches.keys()):
|
||||
if key.lower() == branch_name.lower() or branches[key].get("name", "").lower() == branch_name.lower():
|
||||
del branches[key]
|
||||
registry["branches"] = branches
|
||||
else:
|
||||
registry["branches"] = [
|
||||
b for b in branches
|
||||
if b.get("name", "").lower() != branch_name.lower()
|
||||
]
|
||||
registry["metadata"]["total_branches"] = len(_branches_as_list(registry["branches"]))
|
||||
return save_registry(registry_path, registry)
|
||||
|
||||
|
||||
def _error_result(branch_name, msg, archive_path="", registry_updated=False):
|
||||
"""Build a failure result dict."""
|
||||
return {
|
||||
"branch": branch_name,
|
||||
"success": False,
|
||||
"archive_path": archive_path,
|
||||
"registry_updated": registry_updated,
|
||||
"error": msg,
|
||||
}
|
||||
|
||||
|
||||
def delete_branch(
|
||||
branch_name: str,
|
||||
confirm: bool = True,
|
||||
@@ -44,22 +116,8 @@ def delete_branch(
|
||||
) -> dict:
|
||||
"""Archive and deregister a branch.
|
||||
|
||||
Workflow:
|
||||
1. Resolve branch path from AIPASS_REGISTRY.json
|
||||
2. Verify branch exists on filesystem
|
||||
3. If confirm=True -> print what will happen, prompt "Are you sure? (y/N)"
|
||||
4. If dry_run -> print summary, return
|
||||
5. Create archive directory: {repo_root}/.archive/deleted_branches/{name}_{timestamp}/
|
||||
6. Copy entire branch directory to archive (shutil.copytree)
|
||||
7. Remove branch from AIPASS_REGISTRY.json
|
||||
8. Remove branch directory (shutil.rmtree)
|
||||
9. Log and return summary
|
||||
|
||||
SAFETY:
|
||||
- Cannot delete spawn (self-protection)
|
||||
- Cannot delete devpulse (orchestration hub protection)
|
||||
- Cannot delete drone (routing infrastructure protection)
|
||||
- Archive ALWAYS created before deletion
|
||||
SAFETY: Cannot delete spawn, devpulse, or drone (protected).
|
||||
Archive ALWAYS created before deletion.
|
||||
|
||||
Returns:
|
||||
Dict with deletion results.
|
||||
@@ -68,52 +126,25 @@ def delete_branch(
|
||||
if branch_name.lower() in _PROTECTED_BRANCHES:
|
||||
msg = f"Cannot delete '{branch_name}' — protected branch ({', '.join(sorted(_PROTECTED_BRANCHES))})"
|
||||
logger.warning(f"[delete] {msg}")
|
||||
return {
|
||||
"branch": branch_name,
|
||||
"success": False,
|
||||
"archive_path": "",
|
||||
"registry_updated": False,
|
||||
"error": msg,
|
||||
}
|
||||
return _error_result(branch_name, msg)
|
||||
|
||||
# 1. Resolve branch path from registry
|
||||
registry_path = find_registry()
|
||||
project_root = registry_path.parent
|
||||
registry = load_registry(registry_path)
|
||||
branch_entry = None
|
||||
branch_dir = None
|
||||
|
||||
for entry in _branches_as_list(registry.get("branches", [])):
|
||||
if entry.get("name", "").lower() == branch_name.lower():
|
||||
branch_entry = entry
|
||||
rel_path = entry.get("path", "")
|
||||
if rel_path:
|
||||
branch_dir = (_REPO_ROOT / rel_path).resolve()
|
||||
break
|
||||
branch_entry, branch_dir = _resolve_branch_dir(branch_name, registry_path, registry)
|
||||
|
||||
if branch_entry is None:
|
||||
msg = f"Branch '{branch_name}' not found in registry"
|
||||
logger.warning(f"[delete] {msg}")
|
||||
return {
|
||||
"branch": branch_name,
|
||||
"success": False,
|
||||
"archive_path": "",
|
||||
"registry_updated": False,
|
||||
"error": msg,
|
||||
}
|
||||
return _error_result(branch_name, msg)
|
||||
|
||||
# 2. Verify branch exists on filesystem
|
||||
if branch_dir is None or not branch_dir.is_dir():
|
||||
msg = f"Branch directory does not exist: {branch_dir}"
|
||||
logger.warning(f"[delete] {msg}")
|
||||
return {
|
||||
"branch": branch_name,
|
||||
"success": False,
|
||||
"archive_path": "",
|
||||
"registry_updated": False,
|
||||
"error": msg,
|
||||
}
|
||||
return _error_result(branch_name, msg)
|
||||
|
||||
# 3. Confirmation prompt
|
||||
# 2. Confirmation prompt
|
||||
if confirm and not dry_run:
|
||||
try:
|
||||
answer = input("Are you sure? (y/N): ").strip().lower()
|
||||
@@ -121,17 +152,11 @@ def delete_branch(
|
||||
logger.warning("Delete confirmation prompt interrupted: %s", e)
|
||||
answer = ""
|
||||
if answer != "y":
|
||||
return {
|
||||
"branch": branch_name,
|
||||
"success": False,
|
||||
"archive_path": "",
|
||||
"registry_updated": False,
|
||||
"error": "Cancelled by user",
|
||||
}
|
||||
return _error_result(branch_name, "Cancelled by user")
|
||||
|
||||
# 4. Dry run — report and return
|
||||
# 3. Dry run — report and return
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
archive_dir = _REPO_ROOT / ".archive" / "deleted_branches" / f"{branch_name}_{timestamp}"
|
||||
archive_dir = project_root / ".archive" / "deleted_branches" / f"{branch_name}_{timestamp}"
|
||||
|
||||
if dry_run:
|
||||
logger.info(f"[delete] Dry run: would archive {branch_name} to {archive_dir}")
|
||||
@@ -143,57 +168,26 @@ def delete_branch(
|
||||
"dry_run": True,
|
||||
}
|
||||
|
||||
# 5. Create archive
|
||||
archive_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copytree(str(branch_dir), str(archive_dir))
|
||||
logger.info(f"[delete] Archived {branch_name} to {archive_dir}")
|
||||
except Exception as exc:
|
||||
msg = f"Failed to create archive: {exc}"
|
||||
logger.error(f"[delete] {msg}")
|
||||
return {
|
||||
"branch": branch_name,
|
||||
"success": False,
|
||||
"archive_path": "",
|
||||
"registry_updated": False,
|
||||
"error": msg,
|
||||
}
|
||||
|
||||
# 6. Remove from registry
|
||||
branches = registry.get("branches", [])
|
||||
if isinstance(branches, dict):
|
||||
# Dict format: remove by key (try both cases)
|
||||
for key in list(branches.keys()):
|
||||
if key.lower() == branch_name.lower() or branches[key].get("name", "").lower() == branch_name.lower():
|
||||
del branches[key]
|
||||
registry["branches"] = branches
|
||||
else:
|
||||
registry["branches"] = [
|
||||
b for b in branches
|
||||
if b.get("name", "").lower() != branch_name.lower()
|
||||
]
|
||||
registry["metadata"]["total_branches"] = len(_branches_as_list(registry["branches"]))
|
||||
registry_updated = save_registry(registry_path, registry)
|
||||
# 4. Create archive
|
||||
archive_error = _archive_branch(branch_dir, archive_dir)
|
||||
if archive_error:
|
||||
return _error_result(branch_name, archive_error)
|
||||
|
||||
# 5. Remove from registry
|
||||
registry_updated = _remove_from_registry(registry, branch_name, registry_path)
|
||||
if registry_updated:
|
||||
logger.info(f"[delete] Removed {branch_name} from registry")
|
||||
else:
|
||||
logger.error(f"[delete] Failed to update registry after removing {branch_name}")
|
||||
|
||||
# 7. Remove branch directory
|
||||
# 6. Remove branch directory
|
||||
try:
|
||||
shutil.rmtree(str(branch_dir))
|
||||
logger.info(f"[delete] Removed branch directory: {branch_dir}")
|
||||
except Exception as exc:
|
||||
msg = f"Archive created but failed to remove directory: {exc}"
|
||||
logger.error(f"[delete] {msg}")
|
||||
return {
|
||||
"branch": branch_name,
|
||||
"success": False,
|
||||
"archive_path": str(archive_dir),
|
||||
"registry_updated": registry_updated,
|
||||
"error": msg,
|
||||
}
|
||||
return _error_result(branch_name, msg, str(archive_dir), registry_updated)
|
||||
|
||||
json_handler.log_operation("delete_executed", data={"branch": branch_name})
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ SKIP_NAMES = {"__pycache__", ".git", ".template_registry.json"}
|
||||
|
||||
def ensure_directory(path):
|
||||
"""Create directory and parents if they don't exist."""
|
||||
if path is None:
|
||||
raise ValueError("ensure_directory received None path — caller passed invalid target")
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
|
||||
@@ -98,9 +98,14 @@ def grant_passport(
|
||||
# Regenerate template registry
|
||||
regenerate_template_registry(target)
|
||||
|
||||
# Register in AIPASS_REGISTRY.json
|
||||
# Register in AIPASS_REGISTRY.json (store relative path for portability)
|
||||
try:
|
||||
registry_branch_path = str(target.relative_to(reg_path.parent))
|
||||
except ValueError:
|
||||
logger.warning("[passport] Cannot relativize path %s to registry %s, storing absolute", target, reg_path.parent)
|
||||
registry_branch_path = str(target)
|
||||
registry_updated = add_to_registry(
|
||||
reg_path, branch_upper, str(target), detected_profile,
|
||||
reg_path, branch_upper, registry_branch_path, detected_profile,
|
||||
f"@{branch_lower}", purpose or "Birthright citizen - purpose TBD",
|
||||
)
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ from aipass.spawn.apps.handlers.placeholders import build_replacements_dict, rep
|
||||
from aipass.spawn.apps.handlers.registry import find_registry, load_registry, _branches_as_list
|
||||
from aipass.spawn.apps.handlers.json import json_handler
|
||||
|
||||
# Repo root — resolved from spawn package location
|
||||
_REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -347,9 +345,10 @@ def _resolve_branch_path(branch_name: str) -> Path | None:
|
||||
"""Resolve a branch name to its absolute directory path via the registry.
|
||||
|
||||
Tries both the exact name and common case variants.
|
||||
Registry paths are relative to repo root.
|
||||
Registry paths are relative to registry parent (project root).
|
||||
"""
|
||||
registry_path = find_registry()
|
||||
project_root = registry_path.parent
|
||||
registry = load_registry(registry_path)
|
||||
|
||||
for branch in _branches_as_list(registry.get("branches", [])):
|
||||
@@ -357,7 +356,7 @@ def _resolve_branch_path(branch_name: str) -> Path | None:
|
||||
if reg_name.lower() == branch_name.lower():
|
||||
rel_path = branch.get("path", "")
|
||||
if rel_path:
|
||||
return (_REPO_ROOT / rel_path).resolve()
|
||||
return (project_root / rel_path).resolve()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -61,6 +61,19 @@ def handle_command(command: str, args: list) -> bool:
|
||||
return handle_passport(args) == 0
|
||||
|
||||
|
||||
def _search_src_subdirs(src_dir: Path, dirname: str) -> Path | None:
|
||||
"""Search src/*/dirname for an existing directory."""
|
||||
if not src_dir.is_dir():
|
||||
return None
|
||||
for sub in src_dir.iterdir():
|
||||
if not sub.is_dir():
|
||||
continue
|
||||
candidate = sub / dirname
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_target(dirname: str) -> Path:
|
||||
"""Resolve a @dirname to a filesystem path.
|
||||
|
||||
@@ -87,13 +100,9 @@ def _resolve_target(dirname: str) -> Path:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
# Search src/*/dirname (e.g., src/aipass/target)
|
||||
src_dir = project_root / "src"
|
||||
if src_dir.is_dir():
|
||||
for sub in src_dir.iterdir():
|
||||
if sub.is_dir():
|
||||
candidate = sub / dirname
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
found = _search_src_subdirs(project_root / "src", dirname)
|
||||
if found:
|
||||
return found
|
||||
except Exception as exc:
|
||||
logger.warning("[passport] Registry lookup failed during target resolution: %s", exc)
|
||||
|
||||
|
||||
@@ -1,350 +1,390 @@
|
||||
{
|
||||
"metadata": {
|
||||
"description": "Template registry for tracking files",
|
||||
"generated": true
|
||||
"version": "1.0.0",
|
||||
"last_updated": "2026-04-10",
|
||||
"description": "Template file tracking registry for ID-based updates"
|
||||
},
|
||||
"files": {
|
||||
"f001": {
|
||||
"path": ".ai_mail.local/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "49299c242a018d73",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "49299c242a01",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f002": {
|
||||
"path": ".ai_mail.local/inbox.json",
|
||||
"name": "inbox.json",
|
||||
"content_hash": "c9702fe2cc21b748",
|
||||
"content_hash": "c9702fe2cc21",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f003": {
|
||||
"path": ".aipass/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "f42d87684fdfb1af",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "f42d87684fdf",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f004": {
|
||||
"path": ".aipass/aipass_local_prompt.md",
|
||||
"name": "aipass_local_prompt.md",
|
||||
"content_hash": "5f8e2a3859a99906",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "5f8e2a3859a9",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f005": {
|
||||
"path": ".archive/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "93d3fcb74f234b1d",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "93d3fcb74f23",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f006": {
|
||||
"path": ".claude/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "adb0ce8c53c1da2d",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "adb0ce8c53c1",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f007": {
|
||||
"path": ".claude/settings.local.json",
|
||||
"name": "settings.local.json",
|
||||
"content_hash": "eacf065629cd9c03",
|
||||
"content_hash": "eacf065629cd",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f008": {
|
||||
"path": ".gitignore",
|
||||
"name": ".gitignore",
|
||||
"content_hash": "841dedb922da7ddb",
|
||||
"content_hash": "841dedb922da",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f009": {
|
||||
"path": ".seedgo/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "ea03468bbf16f248",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "ea03468bbf16",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f010": {
|
||||
"path": ".seedgo/bypass.json",
|
||||
"name": "bypass.json",
|
||||
"content_hash": "0ac90a35515b35c2",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "0ac90a35515b",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f024": {
|
||||
"path": ".spawn/.registry_ignore.json",
|
||||
"name": ".registry_ignore.json",
|
||||
"content_hash": "34f5e7ff7e01",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f043": {
|
||||
"path": ".spawn/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "e22ad5337efd",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f011": {
|
||||
"path": ".trinity/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "f461c9b16fc566a0",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "f461c9b16fc5",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f012": {
|
||||
"path": ".trinity/local.json",
|
||||
"name": "local.json",
|
||||
"content_hash": "f4e62d2d12bada2e",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "f4e62d2d12ba",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f013": {
|
||||
"path": ".trinity/observations.json",
|
||||
"name": "observations.json",
|
||||
"content_hash": "25b957960a5939a3",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "25b957960a59",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f014": {
|
||||
"path": ".trinity/passport.json",
|
||||
"name": "passport.json",
|
||||
"content_hash": "04c2ead2a4284027",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "9b1d3a691272",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f015": {
|
||||
"path": "CLAUDE.md",
|
||||
"name": "CLAUDE.md",
|
||||
"content_hash": "e24c75348c4faa57",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "e24c75348c4f",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f016": {
|
||||
"path": "DASHBOARD.local.json",
|
||||
"name": "DASHBOARD.local.json",
|
||||
"content_hash": "88360d943d4bacb6",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "88360d943d4b",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f017": {
|
||||
"path": "README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "ad99517a50f96ba7",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "ad99517a50f9",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f018": {
|
||||
"path": "STATUS.local.md",
|
||||
"name": "STATUS.local.md",
|
||||
"content_hash": "059295ad4d4e62eb",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "059295ad4d4e",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f019": {
|
||||
"path": "apps/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "92a956009e0e22dc",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "92a956009e0e",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f020": {
|
||||
"path": "apps/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "41b011f487af14dc",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "41b011f487af",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f021": {
|
||||
"path": "apps/handlers/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "2e4f4a0c1b474ffa",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "2e4f4a0c1b47",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f022": {
|
||||
"path": "apps/handlers/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "55afc5eddbe04134",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "55afc5eddbe0",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f023": {
|
||||
"path": "apps/modules/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "a4cf0a8e3b4ff993",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "a4cf0a8e3b4f",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f024": {
|
||||
"f044": {
|
||||
"path": "apps/modules/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "e3b0c44298fc1c14",
|
||||
"content_hash": "e3b0c44298fc",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f025": {
|
||||
"path": "apps/plugins/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "d1e4e2b98c384ca2",
|
||||
"has_branch_placeholder": true
|
||||
},
|
||||
"f026": {
|
||||
"path": "apps/plugins/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "e3b0c44298fc1c14",
|
||||
"content_hash": "d1e4e2b98c38",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f027": {
|
||||
"path": "apps/{{BRANCH}}.py",
|
||||
"name": "{{BRANCH}}.py",
|
||||
"content_hash": "fc71b424c10bb172",
|
||||
"content_hash": "e3893ff95aa5",
|
||||
"has_branch_placeholder": true
|
||||
},
|
||||
"f028": {
|
||||
"path": "artifacts/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "de20d11e5cfd3586",
|
||||
"has_branch_placeholder": true
|
||||
},
|
||||
"f029": {
|
||||
"path": "artifacts/birth_certificate.json",
|
||||
"name": "birth_certificate.json",
|
||||
"content_hash": "1ec401f4e397eec7",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "de20d11e5cfd",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f030": {
|
||||
"path": "docs/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "2434da568727499d",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "2434da568727",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f031": {
|
||||
"path": "docs.local/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "c19d8872ea2c2c25",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "c19d8872ea2c",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f032": {
|
||||
"path": "docs.local/sub_agent_drops/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "e3e5a6b9c9c51817",
|
||||
"content_hash": "e3e5a6b9c9c5",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f033": {
|
||||
"path": "dropbox/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "9e1e9b71f93b4cde",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "9e1e9b71f93b",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f034": {
|
||||
"path": "logs/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "4ca207af6bd3a648",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "4ca207af6bd3",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f035": {
|
||||
"path": "pytest.ini",
|
||||
"name": "pytest.ini",
|
||||
"content_hash": "7b39ba7bca4025c5",
|
||||
"content_hash": "7b39ba7bca40",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f036": {
|
||||
"path": "templates/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "73b020b003f900e5",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "73b020b003f9",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f037": {
|
||||
"path": "tests/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "c157895c9b27d3ba",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "c157895c9b27",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f038": {
|
||||
"path": "tests/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "881f06bb6574d2f7",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "881f06bb6574",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f039": {
|
||||
"path": "tests/conftest.py",
|
||||
"name": "conftest.py",
|
||||
"content_hash": "5d98b049957b24a4",
|
||||
"content_hash": "5d98b049957b",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f040": {
|
||||
"path": "tools/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "3c7eaedb16ac2695",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "3c7eaedb16ac",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f041": {
|
||||
"path": "{{BRANCH}}_json/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "e64fa555e7b8e20f",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "e64fa555e7b8",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f042": {
|
||||
"path": "{{BRANCH}}_json/custom_config/README.md",
|
||||
"name": "README.md",
|
||||
"content_hash": "28e9ae3735636611",
|
||||
"has_branch_placeholder": true
|
||||
"content_hash": "28e9ae373563",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f029": {
|
||||
"path": "artifacts/birth_certificate.json",
|
||||
"name": "birth_certificate.json",
|
||||
"content_hash": "0b6e4319781e",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"f026": {
|
||||
"path": "apps/plugins/__init__.py",
|
||||
"name": "__init__.py",
|
||||
"content_hash": "e3b0c44298fc",
|
||||
"has_branch_placeholder": false
|
||||
}
|
||||
},
|
||||
"directories": {
|
||||
"d001": {
|
||||
"path": ".ai_mail.local",
|
||||
"name": ".ai_mail.local"
|
||||
"name": ".ai_mail.local",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d002": {
|
||||
"path": ".ai_mail.local/sent",
|
||||
"name": "sent"
|
||||
"name": "sent",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d003": {
|
||||
"path": ".aipass",
|
||||
"name": ".aipass"
|
||||
"name": ".aipass",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d004": {
|
||||
"path": ".archive",
|
||||
"name": ".archive"
|
||||
"name": ".archive",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d005": {
|
||||
"path": ".claude",
|
||||
"name": ".claude"
|
||||
"name": ".claude",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d006": {
|
||||
"path": ".seedgo",
|
||||
"name": ".seedgo"
|
||||
"name": ".seedgo",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d023": {
|
||||
"path": ".spawn",
|
||||
"name": ".spawn",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d007": {
|
||||
"path": ".trinity",
|
||||
"name": ".trinity"
|
||||
"name": ".trinity",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d008": {
|
||||
"path": "apps",
|
||||
"name": "apps"
|
||||
"name": "apps",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d009": {
|
||||
"path": "apps/handlers",
|
||||
"name": "handlers"
|
||||
"name": "handlers",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d010": {
|
||||
"path": "apps/modules",
|
||||
"name": "modules"
|
||||
"name": "modules",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d011": {
|
||||
"path": "apps/plugins",
|
||||
"name": "plugins"
|
||||
"name": "plugins",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d012": {
|
||||
"path": "artifacts",
|
||||
"name": "artifacts"
|
||||
"name": "artifacts",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d013": {
|
||||
"path": "docs",
|
||||
"name": "docs"
|
||||
"name": "docs",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d014": {
|
||||
"path": "docs.local",
|
||||
"name": "docs.local"
|
||||
"name": "docs.local",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d015": {
|
||||
"path": "docs.local/sub_agent_drops",
|
||||
"name": "sub_agent_drops"
|
||||
"name": "sub_agent_drops",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d016": {
|
||||
"path": "dropbox",
|
||||
"name": "dropbox"
|
||||
"name": "dropbox",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d017": {
|
||||
"path": "logs",
|
||||
"name": "logs"
|
||||
"name": "logs",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d018": {
|
||||
"path": "templates",
|
||||
"name": "templates"
|
||||
"name": "templates",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d019": {
|
||||
"path": "tests",
|
||||
"name": "tests"
|
||||
"name": "tests",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d020": {
|
||||
"path": "tools",
|
||||
"name": "tools"
|
||||
"name": "tools",
|
||||
"has_branch_placeholder": false
|
||||
},
|
||||
"d021": {
|
||||
"path": "{{BRANCH}}_json",
|
||||
"name": "{{BRANCH}}_json"
|
||||
"name": "{{BRANCH}}_json",
|
||||
"has_branch_placeholder": true
|
||||
},
|
||||
"d022": {
|
||||
"path": "{{BRANCH}}_json/custom_config",
|
||||
"name": "custom_config"
|
||||
"name": "custom_config",
|
||||
"has_branch_placeholder": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"identity": {
|
||||
"citizen_class": "builder",
|
||||
"role": "{{ROLE}}",
|
||||
"purpose": "",
|
||||
"purpose": "{{PURPOSE_BRIEF}}",
|
||||
"what_i_do": [],
|
||||
"what_i_dont_do": []
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ def discover_modules() -> List[Any]:
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"apps.modules.{file_path.stem}"
|
||||
module_name = f"aipass.{{BRANCH}}.apps.modules.{file_path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"creator": "SYSTEM",
|
||||
"owner": "{{BRANCHNAME}}",
|
||||
"rarity": "unique",
|
||||
"description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, registered using '{{PROFILE}}' template. Purpose: {{PURPOSE_BRIEF}}",
|
||||
"description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, builder class. Purpose: {{PURPOSE_BRIEF}}",
|
||||
"metadata": {
|
||||
"citizen_number": "{{CITIZEN_NUMBER}}",
|
||||
"template": "{{PROFILE}}",
|
||||
"citizen_class": "builder",
|
||||
"purpose": "{{PURPOSE_BRIEF}}"
|
||||
},
|
||||
"created_at": "{{DATE}}"
|
||||
|
||||
@@ -13,6 +13,8 @@ import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.spawn.apps.handlers.file_ops import (
|
||||
SKIP_NAMES,
|
||||
_replace_path_placeholders,
|
||||
@@ -68,6 +70,11 @@ class TestEnsureDirectory:
|
||||
assert target.exists()
|
||||
assert target.is_dir()
|
||||
|
||||
def test_ensure_directory_none_raises_valueerror(self) -> None:
|
||||
"""Passing None raises ValueError with clear message."""
|
||||
with pytest.raises(ValueError, match="ensure_directory received None path"):
|
||||
ensure_directory(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# copy_template
|
||||
|
||||
@@ -119,8 +119,7 @@ class TestDeleteBranch:
|
||||
"""Successful delete should archive the branch and remove from registry."""
|
||||
from aipass.spawn.apps.handlers.delete_ops import delete_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = delete_branch("test_api", confirm=False)
|
||||
|
||||
@@ -146,8 +145,7 @@ class TestDeleteBranch:
|
||||
"""Cannot delete spawn (self-protection)."""
|
||||
from aipass.spawn.apps.handlers.delete_ops import delete_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = delete_branch("spawn", confirm=False)
|
||||
|
||||
@@ -158,8 +156,7 @@ class TestDeleteBranch:
|
||||
"""Cannot delete devpulse (orchestration hub protection)."""
|
||||
from aipass.spawn.apps.handlers.delete_ops import delete_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = delete_branch("devpulse", confirm=False)
|
||||
|
||||
@@ -170,8 +167,7 @@ class TestDeleteBranch:
|
||||
"""Cannot delete drone (routing infrastructure protection)."""
|
||||
from aipass.spawn.apps.handlers.delete_ops import delete_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = delete_branch("drone", confirm=False)
|
||||
|
||||
@@ -182,8 +178,7 @@ class TestDeleteBranch:
|
||||
"""Dry run should NOT delete or archive anything."""
|
||||
from aipass.spawn.apps.handlers.delete_ops import delete_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = delete_branch("test_api", confirm=False, dry_run=True)
|
||||
|
||||
@@ -202,8 +197,7 @@ class TestDeleteBranch:
|
||||
"""Deleting a branch not in registry should fail gracefully."""
|
||||
from aipass.spawn.apps.handlers.delete_ops import delete_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = delete_branch("nonexistent", confirm=False)
|
||||
|
||||
@@ -214,8 +208,7 @@ class TestDeleteBranch:
|
||||
"""Cancelling confirmation should not delete."""
|
||||
from aipass.spawn.apps.handlers.delete_ops import delete_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry), \
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry), \
|
||||
patch("builtins.input", return_value="n"):
|
||||
|
||||
result = delete_branch("test_api", confirm=True)
|
||||
@@ -736,8 +729,7 @@ class TestHandleDelete:
|
||||
"""handle_delete should reject protected branches."""
|
||||
from aipass.spawn.apps.modules.delete import handle_delete
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \
|
||||
patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = handle_delete(["--yes", "@spawn"])
|
||||
|
||||
|
||||
@@ -201,8 +201,7 @@ class TestUpdateBranch:
|
||||
assert not meta_path.exists()
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = update_branch("test_branch")
|
||||
|
||||
@@ -220,8 +219,7 @@ class TestUpdateBranch:
|
||||
dashboard_before = (branch_dir / "DASHBOARD.local.json").read_text()
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = update_branch("test_branch", dry_run=True)
|
||||
|
||||
@@ -248,8 +246,7 @@ class TestUpdateBranch:
|
||||
original_content = branch_py_content
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = update_branch("test_branch")
|
||||
|
||||
@@ -282,8 +279,7 @@ class TestUpdateBranch:
|
||||
reg_path.write_text(json.dumps(reg, indent=2) + "\n")
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = update_branch("test_branch")
|
||||
|
||||
@@ -302,8 +298,7 @@ class TestUpdateBranch:
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = update_branch("nonexistent_branch")
|
||||
|
||||
@@ -329,8 +324,7 @@ class TestUpdateBranch:
|
||||
reg_path.write_text(json.dumps(reg, indent=2) + "\n")
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = update_branch("test_branch")
|
||||
|
||||
@@ -373,8 +367,7 @@ class TestUpdateBranch:
|
||||
save_branch_meta(branch_dir, meta)
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = update_branch("test_branch")
|
||||
|
||||
@@ -398,8 +391,7 @@ class TestUpdateAll:
|
||||
from aipass.spawn.apps.handlers.update_ops import update_all
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
results = update_all()
|
||||
|
||||
@@ -434,8 +426,7 @@ class TestUpdateAll:
|
||||
mock_registry.write_text(json.dumps(reg, indent=2) + "\n")
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
results = update_all()
|
||||
|
||||
@@ -459,8 +450,7 @@ class TestHandleUpdate:
|
||||
from aipass.spawn.apps.modules.update import handle_update
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = handle_update(["@test_branch"])
|
||||
|
||||
@@ -471,8 +461,7 @@ class TestHandleUpdate:
|
||||
from aipass.spawn.apps.modules.update import handle_update
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = handle_update(["--dry-run", "@test_branch"])
|
||||
|
||||
@@ -493,8 +482,7 @@ class TestHandleUpdate:
|
||||
from aipass.spawn.apps.modules.update import handle_update
|
||||
|
||||
with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \
|
||||
patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path):
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry):
|
||||
|
||||
result = handle_update(["builder", "--all"])
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Event bus and error dispatch for AIPass. I detect errors, fingerprint them, gate
|
||||
## Architecture
|
||||
```
|
||||
trigger.py (entry point — auto-discovers modules/)
|
||||
├── core.py → Event bus: fire/on/off/status (12 events, 12 handlers)
|
||||
├── core.py → Event bus: fire/on/off/status (14 events, 14 handlers)
|
||||
├── errors.py → Error registry CLI: list/detail/suppress/resolve/stats/circuit-breaker
|
||||
├── medic.py → Medic toggle: on/off/status/mute/unmute
|
||||
├── log_events.py → Centralized log watcher (system_logs/)
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
"file": "apps/config.py",
|
||||
"standard": "error_handling",
|
||||
"lines": [
|
||||
31
|
||||
36
|
||||
],
|
||||
"pattern": "except Exception: pass",
|
||||
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
|
||||
@@ -128,16 +128,16 @@
|
||||
"file": "apps/modules/core.py",
|
||||
"standard": "error_handling",
|
||||
"lines": [
|
||||
160
|
||||
61
|
||||
],
|
||||
"pattern": "except Exception: pass",
|
||||
"reason": "Deferred event queue handler execution \u2014 logging would cause infinite recursion (logger fires events via trigger). Same as silent_catch bypass."
|
||||
"pattern": "except ImportError",
|
||||
"reason": "Handler import failure in _ensure_initialized — has logger.warning, not actually silent. Checker false positive on try block line."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/json/json_handler.py",
|
||||
"standard": "error_handling",
|
||||
"lines": [
|
||||
24
|
||||
31
|
||||
],
|
||||
"pattern": "except Exception: pass",
|
||||
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
|
||||
@@ -146,7 +146,7 @@
|
||||
"file": "apps/handlers/events/plan_file.py",
|
||||
"standard": "error_handling",
|
||||
"lines": [
|
||||
58
|
||||
59
|
||||
],
|
||||
"pattern": "except Exception: pass",
|
||||
"reason": "Meta-logging helper _log_error() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
|
||||
@@ -155,7 +155,7 @@
|
||||
"file": "apps/handlers/events/memory_threshold_exceeded.py",
|
||||
"standard": "error_handling",
|
||||
"lines": [
|
||||
46
|
||||
47
|
||||
],
|
||||
"pattern": "except Exception: pass",
|
||||
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
|
||||
@@ -182,7 +182,7 @@
|
||||
"file": "apps/handlers/events/error_detected.py",
|
||||
"standard": "error_handling",
|
||||
"lines": [
|
||||
54
|
||||
53
|
||||
],
|
||||
"pattern": "except Exception: pass",
|
||||
"reason": "Meta-logging helper _log_warning() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
|
||||
@@ -373,6 +373,24 @@
|
||||
"standard": "silent_catch",
|
||||
"lines": [247],
|
||||
"reason": "Exception is handled: calls error() to display user-facing message with suggestion. Not a silent catch \u2014 the user sees the error output."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/core.py",
|
||||
"standard": "silent_catch",
|
||||
"lines": [26, 171, 175],
|
||||
"reason": "Line 26: ImportError fallback to rich Console. Lines 171/175: _coerce_value() type-testing try/except — intentional control flow for int/float/str coercion."
|
||||
},
|
||||
{
|
||||
"file": "apps/config.py",
|
||||
"standard": "silent_catch",
|
||||
"lines": [37, 66, 96],
|
||||
"reason": "Line 37: meta-logging _log_warning() — cannot log a failure to log. Line 66: atomic_write_json cleanup of tmp file during error — can't fail on cleanup. Line 96: ImportError fallback with _log_warning + rich Console."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/log_watcher.py",
|
||||
"standard": "silent_catch",
|
||||
"lines": [233],
|
||||
"reason": "Timestamp format parsing loop — tries multiple datetime formats, ValueError is expected control flow on non-matching formats."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -13,9 +13,11 @@ Provides package-relative paths for trigger data directories.
|
||||
Works in both pip-installed and development environments.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -66,6 +68,27 @@ def atomic_write_json(path: Path, data, indent: int = 2, ensure_ascii: bool = Tr
|
||||
raise
|
||||
|
||||
|
||||
@contextmanager
|
||||
def json_file_lock(path: Path):
|
||||
"""Acquire exclusive lock for a JSON file's read-modify-write cycle.
|
||||
|
||||
Uses a .lock sidecar file with fcntl.flock to prevent concurrent
|
||||
processes from corrupting state during read-modify-write. Combine
|
||||
with atomic_write_json for both concurrency and crash safety.
|
||||
|
||||
Args:
|
||||
path: The JSON file to lock (lock acquired on path.with_suffix('.lock'))
|
||||
"""
|
||||
lock_path = path.with_suffix('.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(lock_path, 'w', encoding='utf-8') as lock_f:
|
||||
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
try:
|
||||
|
||||
@@ -45,12 +45,13 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
from aipass.prax.apps.modules.logger import get_direct_logger
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json, json_file_lock
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
logger = get_direct_logger()
|
||||
REGISTRY_FILE = TRIGGER_ROOT / "trigger_json" / "error_registry.json"
|
||||
TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json"
|
||||
CB_STATE_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_cb_state.json"
|
||||
|
||||
VALID_STATUSES = ('new', 'investigating', 'suppressed', 'resolved')
|
||||
VALID_SEVERITIES = ('low', 'medium', 'high', 'critical')
|
||||
@@ -129,79 +130,95 @@ class CircuitBreakerState:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_circuit_breaker_state() -> None:
|
||||
"""Persist circuit breaker state to trigger_config.json.
|
||||
"""Persist full circuit breaker + per-fingerprint state to trigger_cb_state.json.
|
||||
|
||||
Saves the current breaker state, opened_at timestamp, and cooldown
|
||||
under the 'circuit_breaker' key so the state survives process restarts.
|
||||
Saves CB state (including recent_errors, half_open_allow) and per-fingerprint
|
||||
dispatch tracking so the full state survives process restarts.
|
||||
"""
|
||||
try:
|
||||
data: Dict[str, Any] = {}
|
||||
if TRIGGER_CONFIG_FILE.exists():
|
||||
raw = TRIGGER_CONFIG_FILE.read_text(encoding='utf-8').strip()
|
||||
if raw:
|
||||
data = json.loads(raw)
|
||||
data['circuit_breaker'] = {
|
||||
'state': _circuit_breaker.state,
|
||||
'opened_at': _circuit_breaker.opened_at,
|
||||
'cooldown_seconds': _circuit_breaker.cooldown_seconds,
|
||||
}
|
||||
atomic_write_json(TRIGGER_CONFIG_FILE, data)
|
||||
with json_file_lock(CB_STATE_FILE):
|
||||
state_data = {
|
||||
'circuit_breaker': {
|
||||
'state': _circuit_breaker.state,
|
||||
'opened_at': _circuit_breaker.opened_at,
|
||||
'cooldown_seconds': _circuit_breaker.cooldown_seconds,
|
||||
'recent_errors': _circuit_breaker.recent_errors,
|
||||
'summary_sent': _circuit_breaker.summary_sent,
|
||||
'half_open_allow': _circuit_breaker.half_open_allow,
|
||||
},
|
||||
'per_fingerprint': {
|
||||
fp: {
|
||||
'last_dispatch': max(times) if times else 0.0,
|
||||
'count': _fingerprint_dispatch_count.get(fp, 0),
|
||||
}
|
||||
for fp, times in _fingerprint_dispatch_times.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(CB_STATE_FILE, state_data)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save circuit breaker state: %s", exc)
|
||||
|
||||
|
||||
def _load_circuit_breaker_state() -> CircuitBreakerState:
|
||||
"""Load persisted circuit breaker state from trigger_config.json.
|
||||
def _restore_fingerprint_tracking(pf_data: dict) -> None:
|
||||
"""Restore per-fingerprint dispatch tracking from persisted data."""
|
||||
global _fingerprint_dispatch_times, _fingerprint_dispatch_count
|
||||
for fp, info in pf_data.items():
|
||||
last = float(info.get('last_dispatch', 0.0))
|
||||
count = int(info.get('count', 0))
|
||||
if last > 0:
|
||||
_fingerprint_dispatch_times[fp] = [last]
|
||||
_fingerprint_dispatch_count[fp] = count
|
||||
|
||||
If a valid persisted state exists, restores it into a new
|
||||
CircuitBreakerState. Otherwise returns a fresh default instance.
|
||||
|
||||
def _load_circuit_breaker_state() -> CircuitBreakerState:
|
||||
"""Load full circuit breaker state from trigger_cb_state.json.
|
||||
|
||||
Restores CB state including recent_errors and half_open_allow,
|
||||
plus per-fingerprint dispatch tracking into module-level dicts.
|
||||
|
||||
Returns:
|
||||
CircuitBreakerState populated from disk or defaults.
|
||||
"""
|
||||
try:
|
||||
if TRIGGER_CONFIG_FILE.exists():
|
||||
raw = TRIGGER_CONFIG_FILE.read_text(encoding='utf-8').strip()
|
||||
if not raw:
|
||||
return CircuitBreakerState()
|
||||
data = json.loads(raw)
|
||||
cb_data = data.get('circuit_breaker')
|
||||
if isinstance(cb_data, dict) and cb_data.get('state') in ('closed', 'open', 'half_open'):
|
||||
breaker = CircuitBreakerState()
|
||||
breaker.state = cb_data['state']
|
||||
breaker.opened_at = float(cb_data.get('opened_at', 0.0))
|
||||
breaker.cooldown_seconds = int(cb_data.get('cooldown_seconds', breaker.base_cooldown))
|
||||
return breaker
|
||||
if not CB_STATE_FILE.exists():
|
||||
return CircuitBreakerState()
|
||||
raw = CB_STATE_FILE.read_text(encoding='utf-8').strip()
|
||||
if not raw:
|
||||
return CircuitBreakerState()
|
||||
data = json.loads(raw)
|
||||
cb_data = data.get('circuit_breaker')
|
||||
if not isinstance(cb_data, dict) or cb_data.get('state') not in ('closed', 'open', 'half_open'):
|
||||
return CircuitBreakerState()
|
||||
breaker = CircuitBreakerState()
|
||||
breaker.state = cb_data['state']
|
||||
breaker.opened_at = float(cb_data.get('opened_at', 0.0))
|
||||
breaker.cooldown_seconds = int(cb_data.get('cooldown_seconds', breaker.base_cooldown))
|
||||
breaker.recent_errors = [float(t) for t in cb_data.get('recent_errors', [])]
|
||||
breaker.summary_sent = bool(cb_data.get('summary_sent', False))
|
||||
breaker.half_open_allow = bool(cb_data.get('half_open_allow', True))
|
||||
_restore_fingerprint_tracking(data.get('per_fingerprint', {}))
|
||||
return breaker
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load circuit breaker state: %s", exc)
|
||||
return CircuitBreakerState()
|
||||
|
||||
|
||||
def _clear_circuit_breaker_state() -> None:
|
||||
"""Remove persisted circuit breaker state from trigger_config.json.
|
||||
|
||||
Deletes the 'circuit_breaker' key so restarts start with a clean slate.
|
||||
"""
|
||||
"""Remove persisted circuit breaker state file."""
|
||||
try:
|
||||
if TRIGGER_CONFIG_FILE.exists():
|
||||
raw = TRIGGER_CONFIG_FILE.read_text(encoding='utf-8').strip()
|
||||
if not raw:
|
||||
return
|
||||
data = json.loads(raw)
|
||||
if 'circuit_breaker' in data:
|
||||
del data['circuit_breaker']
|
||||
atomic_write_json(TRIGGER_CONFIG_FILE, data)
|
||||
if CB_STATE_FILE.exists():
|
||||
CB_STATE_FILE.unlink()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to clear circuit breaker state: %s", exc)
|
||||
|
||||
|
||||
# Module-level circuit breaker state (restored from disk if available)
|
||||
_circuit_breaker = _load_circuit_breaker_state()
|
||||
|
||||
# Module-level dicts for per-fingerprint dispatch tracking
|
||||
_fingerprint_dispatch_times: Dict[str, List[float]] = {} # fingerprint -> [dispatch_timestamps]
|
||||
_fingerprint_dispatch_count: Dict[str, int] = {} # fingerprint -> total dispatch count
|
||||
|
||||
# Module-level circuit breaker state (restored from disk if available)
|
||||
_circuit_breaker = _load_circuit_breaker_state()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Circuit Breaker
|
||||
@@ -411,6 +428,7 @@ def record_dispatch(fingerprint: str) -> None:
|
||||
_fingerprint_dispatch_times[fingerprint].append(now)
|
||||
_fingerprint_dispatch_count[fingerprint] = \
|
||||
_fingerprint_dispatch_count.get(fingerprint, 0) + 1
|
||||
_save_circuit_breaker_state()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -576,53 +594,54 @@ def report(
|
||||
# invalid arguments are auto-suppressed to avoid noisy dispatch.
|
||||
is_user_error = bool(_USER_ERROR_PATTERNS.search(message))
|
||||
|
||||
registry = _load_registry()
|
||||
now = datetime.now().isoformat()
|
||||
with json_file_lock(REGISTRY_FILE):
|
||||
registry = _load_registry()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
if fingerprint in registry["errors"]:
|
||||
# Existing error - increment count and update last_seen
|
||||
entry = registry["errors"][fingerprint]
|
||||
entry["count"] = entry.get("count", 1) + 1
|
||||
entry["last_seen"] = now
|
||||
# Update log_path if provided (might be from a different log file)
|
||||
if log_path:
|
||||
entry["log_path"] = log_path
|
||||
# Auto-suppress user errors that were previously unsuppressed
|
||||
if is_user_error and entry.get("status") != "suppressed":
|
||||
entry["status"] = "suppressed"
|
||||
entry["suppress_reason"] = "user_error"
|
||||
_save_registry(registry)
|
||||
result = dict(entry)
|
||||
result["is_new"] = False
|
||||
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": entry["count"]})
|
||||
return result
|
||||
else:
|
||||
# New error - create entry
|
||||
initial_status = "suppressed" if is_user_error else "new"
|
||||
initial_suppress_reason = "user_error" if is_user_error else ""
|
||||
if fingerprint in registry["errors"]:
|
||||
# Existing error - increment count and update last_seen
|
||||
entry = registry["errors"][fingerprint]
|
||||
entry["count"] = entry.get("count", 1) + 1
|
||||
entry["last_seen"] = now
|
||||
# Update log_path if provided (might be from a different log file)
|
||||
if log_path:
|
||||
entry["log_path"] = log_path
|
||||
# Auto-suppress user errors that were previously unsuppressed
|
||||
if is_user_error and entry.get("status") != "suppressed":
|
||||
entry["status"] = "suppressed"
|
||||
entry["suppress_reason"] = "user_error"
|
||||
_save_registry(registry)
|
||||
result = dict(entry)
|
||||
result["is_new"] = False
|
||||
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": entry["count"]})
|
||||
return result
|
||||
else:
|
||||
# New error - create entry
|
||||
initial_status = "suppressed" if is_user_error else "new"
|
||||
initial_suppress_reason = "user_error" if is_user_error else ""
|
||||
|
||||
event = ErrorEvent(
|
||||
fingerprint=fingerprint,
|
||||
error_type=error_type,
|
||||
message=message,
|
||||
normalized_message=normalized,
|
||||
component=component,
|
||||
severity=severity,
|
||||
count=1,
|
||||
status=initial_status,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
log_path=log_path,
|
||||
suppress_reason=initial_suppress_reason,
|
||||
source_fix_status="none"
|
||||
)
|
||||
entry_dict = asdict(event)
|
||||
registry["errors"][fingerprint] = entry_dict
|
||||
_save_registry(registry)
|
||||
result = dict(entry_dict)
|
||||
result["is_new"] = True
|
||||
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": 1})
|
||||
return result
|
||||
event = ErrorEvent(
|
||||
fingerprint=fingerprint,
|
||||
error_type=error_type,
|
||||
message=message,
|
||||
normalized_message=normalized,
|
||||
component=component,
|
||||
severity=severity,
|
||||
count=1,
|
||||
status=initial_status,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
log_path=log_path,
|
||||
suppress_reason=initial_suppress_reason,
|
||||
source_fix_status="none"
|
||||
)
|
||||
entry_dict = asdict(event)
|
||||
registry["errors"][fingerprint] = entry_dict
|
||||
_save_registry(registry)
|
||||
result = dict(entry_dict)
|
||||
result["is_new"] = True
|
||||
json_handler.log_operation("error_registered", {"fingerprint": fingerprint[:12], "count": 1})
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to report error for component '%s': %s", component, exc)
|
||||
|
||||
@@ -142,11 +142,10 @@ def report_error(
|
||||
)
|
||||
result["dispatched"] = False
|
||||
|
||||
# Fire event on first occurrence (count==1) for registration and on
|
||||
# second occurrence (count==2) so the handler can apply the dispatch
|
||||
# threshold. Skip all other counts (backoff handles later dispatches).
|
||||
# Fire event on every call — let the error_detected handler decide
|
||||
# when to dispatch via circuit breaker, backoff, and rate limiting.
|
||||
error_count = result.get("count", 1)
|
||||
if not fire_event or (not result.get("is_new", False) and error_count != 2):
|
||||
if not fire_event:
|
||||
return result
|
||||
|
||||
try:
|
||||
|
||||
@@ -350,7 +350,7 @@ def _write_suppression_log(reason: str, branch: str, module: str, message: str)
|
||||
try:
|
||||
suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log"
|
||||
suppressed_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(suppressed_log, 'a') as f:
|
||||
with open(suppressed_log, 'a', encoding='utf-8') as f:
|
||||
f.write(
|
||||
f"{datetime.now().isoformat()} | "
|
||||
f"{reason} - {branch}: {module} - {message[:100]}\n"
|
||||
@@ -364,7 +364,7 @@ def _write_rate_log(reason: str, detail: str) -> None:
|
||||
try:
|
||||
rate_log = TRIGGER_ROOT / "logs" / "rate_limited.log"
|
||||
rate_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(rate_log, 'a') as f:
|
||||
with open(rate_log, 'a', encoding='utf-8') as f:
|
||||
f.write(f"{datetime.now().isoformat()} | {reason}: {detail}\n")
|
||||
except Exception as exc:
|
||||
_log_warning(f"rate log write failed ({reason}): {exc}")
|
||||
@@ -445,6 +445,10 @@ def handle_error_detected(
|
||||
_write_suppression_log(f"First occurrence (count={count}) - waiting for pattern", branch, module, message)
|
||||
return
|
||||
|
||||
# Record error for circuit breaker at detection time (not dispatch time)
|
||||
if _REGISTRY_DISPATCH_AVAILABLE:
|
||||
circuit_breaker_record_error()
|
||||
|
||||
# Callback must be set by module layer before events fire
|
||||
if _send_email is None:
|
||||
return
|
||||
@@ -516,17 +520,23 @@ def handle_error_detected(
|
||||
subject=email_subject,
|
||||
message=notification_message,
|
||||
auto_execute=True,
|
||||
reply_to='@trigger',
|
||||
reply_to='@devpulse',
|
||||
from_branch='@trigger'
|
||||
)
|
||||
|
||||
# Wake the target branch so the email is processed immediately
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
|
||||
wake_branch(recipient, fresh=False, sender='@trigger')
|
||||
except Exception:
|
||||
pass # Silent — email in inbox as fallback
|
||||
|
||||
json_handler.log_operation("dispatch_sent", {"recipient": recipient})
|
||||
|
||||
# Record dispatch for tracking
|
||||
if _REGISTRY_DISPATCH_AVAILABLE and fingerprint:
|
||||
# Medic v2: per-fingerprint dispatch tracking + circuit breaker
|
||||
# Medic v2: per-fingerprint dispatch tracking
|
||||
registry_record_dispatch(fingerprint)
|
||||
circuit_breaker_record_error()
|
||||
else:
|
||||
# Legacy: per-branch rate limiting
|
||||
_record_dispatch(recipient)
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: error_logged.py
|
||||
# Description: Legacy error logged event handler with medic gating (deprecated)
|
||||
# Version: 2.0.0
|
||||
# Description: Legacy error logged event handler — monitor-only (no dispatch)
|
||||
# Version: 3.0.0
|
||||
# Created: 2026-01-31
|
||||
# Modified: 2026-02-25
|
||||
# Modified: 2026-04-10
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Error Logged Event Handler (DEPRECATED)
|
||||
Error Logged Event Handler (Monitor-Only)
|
||||
|
||||
Legacy handler for error_logged events. The primary error dispatch pipeline
|
||||
is now error_detected.py (Medic v2) which provides circuit breaker, per-fingerprint
|
||||
Legacy handler for error_logged events. All dispatch now goes through
|
||||
error_detected.py (Medic v2) which provides circuit breaker, per-fingerprint
|
||||
backoff, and registry-based deduplication.
|
||||
|
||||
This handler remains for backward compatibility with code that fires error_logged
|
||||
events directly. It now includes full medic gating (medic_enabled, branch_muted,
|
||||
rate limiting, devpulse protection) to prevent bypass.
|
||||
|
||||
Event data expected:
|
||||
- branch: Branch where error occurred (e.g., FLOW)
|
||||
- message: Error message text
|
||||
- error_hash: Unique hash for deduplication
|
||||
- timestamp: When the error occurred
|
||||
- log_file: Path to log file
|
||||
- source_module: Module that logged the error
|
||||
- level: Log level (always 'error' for this handler)
|
||||
This handler logs event metadata for monitoring. No email, no wake_branch.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
@@ -49,291 +35,38 @@ def _log_warning(message: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file 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()
|
||||
|
||||
_REPO_ROOT = _find_repo_root()
|
||||
|
||||
TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json"
|
||||
BRANCH_REGISTRY_FILE = _REPO_ROOT / "AIPASS_REGISTRY.json"
|
||||
SUPPRESSED_LOG = TRIGGER_ROOT / "logs" / "medic_suppressed.log"
|
||||
|
||||
# Legacy rate limiting
|
||||
_dispatch_timestamps: Dict[str, List[float]] = {}
|
||||
MAX_DISPATCHES_PER_WINDOW = 3
|
||||
RATE_LIMIT_WINDOW_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
def _is_medic_enabled() -> bool:
|
||||
"""Check if medic dispatch is enabled globally.
|
||||
|
||||
Reads medic_enabled from trigger_config.json.
|
||||
Defaults to True if config is missing or unreadable.
|
||||
|
||||
Returns:
|
||||
True if medic dispatch is enabled
|
||||
"""
|
||||
try:
|
||||
if TRIGGER_CONFIG_FILE.exists():
|
||||
data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8'))
|
||||
return bool(data.get('config', {}).get('medic_enabled', True))
|
||||
except Exception as exc:
|
||||
_log_warning(f"_is_medic_enabled config read failed: {exc}")
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _is_branch_muted(branch_name: str) -> bool:
|
||||
"""Check if a specific branch is muted for medic dispatch.
|
||||
|
||||
Reads muted_branches list from trigger_config.json.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name (case-insensitive)
|
||||
|
||||
Returns:
|
||||
True if branch is in the muted list
|
||||
"""
|
||||
try:
|
||||
if TRIGGER_CONFIG_FILE.exists():
|
||||
data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8'))
|
||||
muted = data.get('config', {}).get('muted_branches', [])
|
||||
return branch_name.lower() in [b.lower() for b in muted]
|
||||
except Exception as exc:
|
||||
_log_warning(f"_is_branch_muted config read failed: {exc}")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _get_registered_emails() -> set:
|
||||
"""Read registered branch emails from AIPASS_REGISTRY.json.
|
||||
|
||||
Returns:
|
||||
Set of registered email addresses (e.g., {'@flow', '@drone'})
|
||||
"""
|
||||
try:
|
||||
if BRANCH_REGISTRY_FILE.exists():
|
||||
data = json.loads(BRANCH_REGISTRY_FILE.read_text(encoding='utf-8'))
|
||||
return {b["email"] for b in data.get("branches", [])}
|
||||
except Exception as exc:
|
||||
_log_warning(f"_get_registered_emails registry read failed: {exc}")
|
||||
return set()
|
||||
return set()
|
||||
|
||||
|
||||
def _is_rate_limited(branch_email: str) -> bool:
|
||||
"""Check if a branch has exceeded the dispatch rate limit.
|
||||
|
||||
Args:
|
||||
branch_email: Target branch email (e.g., '@flow')
|
||||
|
||||
Returns:
|
||||
True if branch has hit the limit (3 dispatches in 10 minutes)
|
||||
"""
|
||||
now = time.time()
|
||||
cutoff = now - RATE_LIMIT_WINDOW_SECONDS
|
||||
|
||||
if branch_email not in _dispatch_timestamps:
|
||||
_dispatch_timestamps[branch_email] = []
|
||||
|
||||
_dispatch_timestamps[branch_email] = [
|
||||
ts for ts in _dispatch_timestamps[branch_email] if ts > cutoff
|
||||
]
|
||||
|
||||
return len(_dispatch_timestamps[branch_email]) >= MAX_DISPATCHES_PER_WINDOW
|
||||
|
||||
|
||||
def _record_dispatch(branch_email: str) -> None:
|
||||
"""Record a dispatch timestamp for rate limiting.
|
||||
|
||||
Args:
|
||||
branch_email: Target branch email (e.g., '@flow')
|
||||
"""
|
||||
if branch_email not in _dispatch_timestamps:
|
||||
_dispatch_timestamps[branch_email] = []
|
||||
_dispatch_timestamps[branch_email].append(time.time())
|
||||
|
||||
|
||||
def _log_suppression(reason: str, branch: str, source_module: str, message: str) -> None:
|
||||
"""Log a suppressed dispatch to medic_suppressed.log.
|
||||
|
||||
Args:
|
||||
reason: Why dispatch was suppressed
|
||||
branch: Target branch name
|
||||
source_module: Module that logged the error
|
||||
message: Error message (truncated to 100 chars)
|
||||
"""
|
||||
try:
|
||||
SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(SUPPRESSED_LOG, 'a') as f:
|
||||
f.write(
|
||||
f"{datetime.now().isoformat()} | "
|
||||
f"{reason} - suppressed dispatch for {branch}: "
|
||||
f"{source_module} - {message[:100]}\n"
|
||||
)
|
||||
except Exception as exc:
|
||||
_log_warning(f"_log_suppression write failed: {exc}")
|
||||
return
|
||||
|
||||
|
||||
def _build_notification_message(
|
||||
error_hash: str,
|
||||
source_module: str,
|
||||
message: str,
|
||||
timestamp: str,
|
||||
log_file: str
|
||||
) -> str:
|
||||
"""Build error notification message with investigation instructions.
|
||||
|
||||
Args:
|
||||
error_hash: Unique error identifier
|
||||
source_module: Module that logged the error
|
||||
message: Error message text
|
||||
timestamp: When error occurred
|
||||
log_file: Path to source log file
|
||||
|
||||
Returns:
|
||||
Formatted message string
|
||||
"""
|
||||
return f"""Error detected - investigate and respond.
|
||||
|
||||
Error ID: {error_hash}
|
||||
Module: {source_module}
|
||||
Timestamp: {timestamp}
|
||||
Log file: {log_file}
|
||||
|
||||
Error message:
|
||||
{message}
|
||||
|
||||
---
|
||||
INVESTIGATION STEPS:
|
||||
1. Check the log file for context around this error
|
||||
2. Identify root cause
|
||||
|
||||
DECISION TREE:
|
||||
- SIMPLE FIX (typo, missing import, config issue):
|
||||
-> Fix it yourself, then report what you did to @devpulse
|
||||
- COMPLEX/UNCLEAR (needs research, affects multiple files):
|
||||
-> Report findings only to @devpulse, recommend action, don't fix
|
||||
- CRITICAL (data loss risk, security, system stability):
|
||||
-> STOP immediately, escalate to @devpulse with full context
|
||||
|
||||
REPORT TO @devpulse:
|
||||
ai_mail email @devpulse "ERROR {error_hash[:8]} - [STATUS]" "Findings..."
|
||||
"""
|
||||
|
||||
|
||||
def handle_error_logged(
|
||||
branch: str | None = None,
|
||||
message: str | None = None,
|
||||
error_hash: str | None = None,
|
||||
timestamp: str | None = None,
|
||||
log_file: str | None = None,
|
||||
source_module: str | None = None,
|
||||
module_name: str | None = None,
|
||||
level: str | None = None, # noqa: ARG001
|
||||
**kwargs: Any # noqa: ARG001
|
||||
**_kwargs: Any
|
||||
) -> None:
|
||||
"""Handle error_logged event with full medic gating.
|
||||
"""Handle error_logged event — monitor-only, no dispatch.
|
||||
|
||||
DEPRECATED: This is the legacy error notification handler. The primary
|
||||
pipeline is error_detected.py (Medic v2). This handler remains for
|
||||
backward compatibility with code that fires error_logged events.
|
||||
|
||||
Gating (matches error_detected.py):
|
||||
1. medic_enabled check (global toggle)
|
||||
2. branch_muted check (per-branch suppression)
|
||||
3. devpulse protection (never auto-trigger)
|
||||
4. Branch validation (unknown branches logged + skipped)
|
||||
5. Rate limiting (3 per 10 minutes per branch)
|
||||
All dispatch now goes through error_detected.py (Medic v2).
|
||||
This handler logs event metadata for monitoring only.
|
||||
|
||||
Args:
|
||||
branch: Branch where error occurred - REQUIRED
|
||||
message: Error message text - REQUIRED
|
||||
error_hash: Unique error identifier - REQUIRED
|
||||
timestamp: When error occurred (defaults to now)
|
||||
log_file: Path to source log file
|
||||
branch: Branch where error occurred
|
||||
message: Error message text
|
||||
error_hash: Unique error identifier
|
||||
source_module: Module that logged the error
|
||||
module_name: Deprecated alias for source_module
|
||||
level: Log level (for reference, unused)
|
||||
**kwargs: Additional event data (ignored)
|
||||
**_kwargs: Additional event data (ignored)
|
||||
"""
|
||||
try:
|
||||
if not branch or not message or not error_hash:
|
||||
return
|
||||
|
||||
# Resolve source_module from either parameter name
|
||||
effective_module = source_module or module_name or "unknown"
|
||||
|
||||
# --- Medic gating (FPLAN-0371 Phase 1) ---
|
||||
|
||||
# Gate 1: Global medic toggle
|
||||
if not _is_medic_enabled():
|
||||
_log_suppression("Medic OFF", branch, effective_module, message)
|
||||
return
|
||||
|
||||
# Gate 2: Per-branch mute
|
||||
if _is_branch_muted(branch):
|
||||
_log_suppression("Branch muted", branch, effective_module, message)
|
||||
return
|
||||
|
||||
# Gate 3: Convert branch name to email format
|
||||
recipient = f"@{branch.lower()}"
|
||||
|
||||
# devpulse is protected from auto-triggering
|
||||
if recipient == '@devpulse':
|
||||
return
|
||||
|
||||
# Gate 4: Validate target branch exists in registry
|
||||
registered_emails = _get_registered_emails()
|
||||
if recipient not in registered_emails:
|
||||
_log_suppression("Unknown branch skipped", branch, effective_module, message)
|
||||
return
|
||||
|
||||
# Gate 5: Rate limiting (3 dispatches per 10 minutes per branch)
|
||||
if _is_rate_limited(recipient):
|
||||
_log_suppression("Rate limited", branch, effective_module, message)
|
||||
return
|
||||
|
||||
# --- Dispatch ---
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.modules.email import deliver_email_to_branch
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
effective_timestamp = timestamp or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
effective_log_file = log_file or "unknown"
|
||||
|
||||
email_subject = f"[ERROR] {effective_module} - investigation needed"
|
||||
|
||||
notification_message = _build_notification_message(
|
||||
error_hash=error_hash,
|
||||
source_module=effective_module,
|
||||
message=message,
|
||||
timestamp=effective_timestamp,
|
||||
log_file=effective_log_file
|
||||
)
|
||||
|
||||
email_data = {
|
||||
"from": "@trigger",
|
||||
"from_name": "TRIGGER",
|
||||
"to": recipient,
|
||||
"subject": email_subject,
|
||||
"message": f"⚡ DISPATCH TASK - READ THIS FIRST ⚡\n\n{notification_message}",
|
||||
"timestamp": effective_timestamp,
|
||||
}
|
||||
deliver_email_to_branch(recipient, email_data)
|
||||
|
||||
# Record dispatch for rate limiting
|
||||
_record_dispatch(recipient)
|
||||
|
||||
json_handler.log_operation("error_logged_event", {"success": True})
|
||||
json_handler.log_operation("error_logged_event", {
|
||||
"branch": branch,
|
||||
"module": effective_module,
|
||||
"error_hash": error_hash,
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
_log_warning(f"handle_error_logged failed: {exc}")
|
||||
|
||||
@@ -134,9 +134,9 @@ def handle_memory_threshold_exceeded(
|
||||
if not branch or not file_name or line_count is None:
|
||||
return
|
||||
|
||||
# Import AI_Mail delivery
|
||||
# Import AI_Mail delivery (modules-level API, not handler-level)
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
|
||||
from aipass.ai_mail.apps.modules.email import deliver_email_to_branch
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def _load_trigger_data() -> Dict[str, Any]:
|
||||
"""Load trigger_data.json with error_catchup section."""
|
||||
try:
|
||||
if TRIGGER_DATA_FILE.exists():
|
||||
with open(TRIGGER_DATA_FILE, 'r') as f:
|
||||
with open(TRIGGER_DATA_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if 'error_catchup' not in data:
|
||||
data['error_catchup'] = {
|
||||
@@ -103,7 +103,7 @@ def _log_suppression(reason: str) -> None:
|
||||
"""
|
||||
try:
|
||||
SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(SUPPRESSED_LOG, 'a') as f:
|
||||
with open(SUPPRESSED_LOG, 'a', encoding='utf-8') as f:
|
||||
f.write(f"{datetime.now().isoformat()} | error_catchup: {reason}\n")
|
||||
except Exception as exc:
|
||||
_log_warning(f"log suppression write failed: {exc}")
|
||||
@@ -349,7 +349,7 @@ def _run_error_catchup(fire_event: Optional[Callable[..., None]] = None) -> None
|
||||
|
||||
if errors and fire_event is not None:
|
||||
for error in errors:
|
||||
fire_event('error_logged', **error)
|
||||
fire_event('error_detected', **error)
|
||||
|
||||
hash_list = list(processed_hashes)
|
||||
max_h = catchup.get('max_hashes', MAX_HASHES)
|
||||
|
||||
@@ -29,7 +29,7 @@ import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Set, Optional, Callable
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT, AIPASS_PKG_ROOT, atomic_write_json
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT, AIPASS_PKG_ROOT, atomic_write_json, json_file_lock
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
from aipass.prax.apps.modules.logger import get_direct_logger
|
||||
@@ -97,6 +97,7 @@ except ImportError:
|
||||
_branch_log_observer: Any = None
|
||||
_active_watcher: Any = None # Reference to BranchLogWatcher for position persistence
|
||||
_seen_error_hashes: Set[str] = set()
|
||||
_fallback_error_counts: Dict[str, int] = {} # Local count per hash when registry unavailable
|
||||
MAX_SEEN_HASHES = 2000 # Limit memory usage
|
||||
|
||||
# Explicit mapping of system_logs filenames to their owning branch.
|
||||
@@ -111,9 +112,9 @@ SYSTEM_LOGS_DIR = AIPASS_PKG_ROOT.parent.parent / "system_logs"
|
||||
# Known branch prefixes that appear in system_logs filenames (<prefix>_<module>.log).
|
||||
# Sorted longest-first so longer prefixes match before shorter ones.
|
||||
_SYSTEM_LOGS_BRANCH_PREFIXES: list = sorted([
|
||||
'ai_mail', 'api', 'backup', 'cli', 'drone', 'flow',
|
||||
'prax', 'trigger', 'seedgo', 'memory', 'The_Commons',
|
||||
'aipass_os', 'aipass_business',
|
||||
'ai_mail', 'api', 'cli', 'drone', 'flow',
|
||||
'prax', 'trigger', 'seedgo', 'memory',
|
||||
'spawn', 'devpulse',
|
||||
], key=len, reverse=True)
|
||||
|
||||
# Event fire callback (set by module, avoids handler importing from modules)
|
||||
@@ -146,11 +147,12 @@ def _save_seen_hashes() -> None:
|
||||
Merges with existing trigger_data.json content to preserve other keys.
|
||||
"""
|
||||
try:
|
||||
data: Dict[str, Any] = {}
|
||||
if TRIGGER_DATA_FILE.exists():
|
||||
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
|
||||
data['seen_error_hashes'] = list(_seen_error_hashes)
|
||||
atomic_write_json(TRIGGER_DATA_FILE, data)
|
||||
with json_file_lock(TRIGGER_DATA_FILE):
|
||||
data: Dict[str, Any] = {}
|
||||
if TRIGGER_DATA_FILE.exists():
|
||||
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
|
||||
data['seen_error_hashes'] = list(_seen_error_hashes)
|
||||
atomic_write_json(TRIGGER_DATA_FILE, data)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save seen hashes: %s", exc)
|
||||
return # Write failure - hashes remain in memory only
|
||||
@@ -188,11 +190,12 @@ def _save_log_positions(positions: Dict[str, int]) -> None:
|
||||
positions: Dict mapping file paths to byte offsets
|
||||
"""
|
||||
try:
|
||||
data: Dict[str, Any] = {}
|
||||
if TRIGGER_DATA_FILE.exists():
|
||||
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
|
||||
data['log_positions'] = positions
|
||||
atomic_write_json(TRIGGER_DATA_FILE, data)
|
||||
with json_file_lock(TRIGGER_DATA_FILE):
|
||||
data: Dict[str, Any] = {}
|
||||
if TRIGGER_DATA_FILE.exists():
|
||||
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8'))
|
||||
data['log_positions'] = positions
|
||||
atomic_write_json(TRIGGER_DATA_FILE, data)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save log positions: %s", exc)
|
||||
return # Write failure - positions remain in memory only
|
||||
@@ -512,13 +515,9 @@ class BranchLogWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else
|
||||
severity='medium'
|
||||
)
|
||||
|
||||
# Fire event for new errors (count == 1) and on second
|
||||
# occurrence (count == 2) so the handler can apply the
|
||||
# dispatch threshold. Subsequent occurrences are silent
|
||||
# until the per-fingerprint backoff schedule allows.
|
||||
# Fire event on every occurrence — let the error_detected
|
||||
# handler decide via circuit breaker, backoff, and rate limiting.
|
||||
error_count = result.get('count', 1)
|
||||
if not result.get('is_new', False) and error_count != 2:
|
||||
return
|
||||
|
||||
# Fire error_detected event with registry data
|
||||
if _fire_event is not None:
|
||||
@@ -551,11 +550,43 @@ class BranchLogWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else
|
||||
branch, module, e
|
||||
)
|
||||
|
||||
# Fallback path: Medic v1 hash-based dedup
|
||||
# Fallback path: retry lazy import of registry, else track count locally
|
||||
error_hash = _generate_error_hash(module, message)
|
||||
|
||||
if _is_duplicate_error(error_hash):
|
||||
# Retry registry import — may have failed at module load but be available now
|
||||
try:
|
||||
from aipass.trigger.apps.handlers.error_registry import report as _lazy_report
|
||||
result = _lazy_report(
|
||||
error_type=parsed['level'],
|
||||
message=message,
|
||||
component=branch,
|
||||
log_path=log_path,
|
||||
severity='medium'
|
||||
)
|
||||
error_count = result.get('count', 1)
|
||||
if not result.get('is_new', False) and error_count != 2:
|
||||
return
|
||||
if _fire_event is not None:
|
||||
_fire_event(
|
||||
'error_detected',
|
||||
branch=branch, module=module, message=message,
|
||||
log_path=log_path,
|
||||
error_hash=result.get('id', error_hash),
|
||||
timestamp=parsed['timestamp'],
|
||||
fingerprint=result.get('fingerprint', ''),
|
||||
registry_id=result.get('id', ''),
|
||||
first_seen=result.get('first_seen', ''),
|
||||
last_seen=result.get('last_seen', ''),
|
||||
count=error_count,
|
||||
)
|
||||
json_handler.log_operation("error_detected_in_log", {"branch": branch, "log_path": log_path})
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Lazy registry import failed in fallback path: %s", exc)
|
||||
|
||||
# Registry truly unavailable — track count locally, fire with count
|
||||
_fallback_error_counts[error_hash] = _fallback_error_counts.get(error_hash, 0) + 1
|
||||
local_count = _fallback_error_counts[error_hash]
|
||||
|
||||
if _fire_event is not None:
|
||||
_fire_event(
|
||||
@@ -565,7 +596,8 @@ class BranchLogWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else
|
||||
message=message,
|
||||
log_path=log_path,
|
||||
error_hash=error_hash,
|
||||
timestamp=parsed['timestamp']
|
||||
timestamp=parsed['timestamp'],
|
||||
count=local_count,
|
||||
)
|
||||
json_handler.log_operation("error_detected_in_log", {"branch": branch, "log_path": log_path})
|
||||
else:
|
||||
|
||||
@@ -22,7 +22,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from aipass.prax.apps.modules.logger import get_direct_logger
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json
|
||||
from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json, json_file_lock
|
||||
from aipass.trigger.apps.handlers.json import json_handler
|
||||
|
||||
logger = get_direct_logger()
|
||||
@@ -87,15 +87,16 @@ def set_enabled(enabled: bool) -> bool:
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
data = read_config()
|
||||
if 'config' not in data:
|
||||
data['config'] = {}
|
||||
data['config']['medic_enabled'] = enabled
|
||||
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
|
||||
with json_file_lock(TRIGGER_CONFIG_FILE):
|
||||
data = read_config()
|
||||
if 'config' not in data:
|
||||
data['config'] = {}
|
||||
data['config']['medic_enabled'] = enabled
|
||||
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
if write_config(data):
|
||||
json_handler.log_operation("state_persisted", {"key": "medic_enabled", "value": enabled})
|
||||
return True
|
||||
if write_config(data):
|
||||
json_handler.log_operation("state_persisted", {"key": "medic_enabled", "value": enabled})
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -142,15 +143,16 @@ def mute_branch(branch_name: str) -> bool:
|
||||
True on success
|
||||
"""
|
||||
clean = _normalize_branch_name(branch_name)
|
||||
data = read_config()
|
||||
if 'config' not in data:
|
||||
data['config'] = {}
|
||||
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
|
||||
if clean not in muted:
|
||||
muted.append(clean)
|
||||
data['config']['muted_branches'] = muted
|
||||
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
|
||||
return write_config(data)
|
||||
with json_file_lock(TRIGGER_CONFIG_FILE):
|
||||
data = read_config()
|
||||
if 'config' not in data:
|
||||
data['config'] = {}
|
||||
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
|
||||
if clean not in muted:
|
||||
muted.append(clean)
|
||||
data['config']['muted_branches'] = muted
|
||||
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
|
||||
return write_config(data)
|
||||
|
||||
|
||||
def unmute_branch(branch_name: str) -> bool:
|
||||
@@ -164,14 +166,15 @@ def unmute_branch(branch_name: str) -> bool:
|
||||
True on success
|
||||
"""
|
||||
clean = _normalize_branch_name(branch_name)
|
||||
data = read_config()
|
||||
if 'config' not in data:
|
||||
data['config'] = {}
|
||||
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
|
||||
muted = [b for b in muted if b != clean]
|
||||
data['config']['muted_branches'] = muted
|
||||
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
|
||||
return write_config(data)
|
||||
with json_file_lock(TRIGGER_CONFIG_FILE):
|
||||
data = read_config()
|
||||
if 'config' not in data:
|
||||
data['config'] = {}
|
||||
muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])]
|
||||
muted = [b for b in muted if b != clean]
|
||||
data['config']['muted_branches'] = muted
|
||||
data['timestamp'] = datetime.now().strftime("%Y-%m-%d")
|
||||
return write_config(data)
|
||||
|
||||
|
||||
def get_suppression_stats() -> Dict[str, Any]:
|
||||
|
||||
@@ -13,9 +13,9 @@ Watches system_logs/ for log file changes.
|
||||
Detects ERROR/WARNING/INFO entries and fires appropriate events.
|
||||
|
||||
Events fired:
|
||||
- error_logged: When ERROR level log detected
|
||||
- error_detected: When ERROR level log detected (Medic v2 pipeline via registry_report)
|
||||
- error_logged: Monitoring-only event (no dispatch)
|
||||
- warning_logged: When WARNING level log detected
|
||||
- log_entry: All log entries (for monitoring systems)
|
||||
|
||||
Architecture:
|
||||
- Trigger OWNS all file watching (filesystem events)
|
||||
@@ -257,7 +257,30 @@ class LogFileWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else o
|
||||
}
|
||||
|
||||
if level == 'error':
|
||||
trigger.fire('error_logged', **event_data)
|
||||
# Route through Medic v2: registry_report() for dedup/count, then error_detected
|
||||
try:
|
||||
from aipass.trigger.apps.handlers.error_registry import report as registry_report
|
||||
result = registry_report(
|
||||
error_type='ERROR',
|
||||
message=message,
|
||||
component=branch,
|
||||
log_path=log_file,
|
||||
severity='medium'
|
||||
)
|
||||
error_count = result.get('count', 1)
|
||||
trigger.fire('error_detected',
|
||||
branch=branch, module=module_name, message=message,
|
||||
log_path=log_file, error_hash=result.get('id', error_hash),
|
||||
timestamp=timestamp,
|
||||
fingerprint=result.get('fingerprint', ''),
|
||||
registry_id=result.get('id', ''),
|
||||
first_seen=result.get('first_seen', ''),
|
||||
last_seen=result.get('last_seen', ''),
|
||||
count=error_count,
|
||||
)
|
||||
except Exception:
|
||||
# Registry unavailable — fire error_logged as monitoring-only fallback
|
||||
trigger.fire('error_logged', **event_data)
|
||||
json_handler.log_operation("system_log_event", {"level": level, "module": module_name})
|
||||
elif level == 'warning':
|
||||
trigger.fire('warning_logged', **event_data)
|
||||
|
||||
@@ -48,6 +48,9 @@ class Trigger:
|
||||
_deferred_queue = [] # Queue for events fired during handling
|
||||
_draining_deferred = False # Prevents nested deferred processing
|
||||
_log_watcher_started = False # Lazy-start flag for log watcher
|
||||
_handler_failures = {} # handler -> consecutive failure count
|
||||
_disabled_handlers = set() # handlers auto-disabled after repeated failures
|
||||
_HANDLER_FAILURE_THRESHOLD = 5 # consecutive failures before auto-disable
|
||||
|
||||
@classmethod
|
||||
def _ensure_initialized(cls):
|
||||
@@ -96,12 +99,25 @@ class Trigger:
|
||||
def _fire_to_handlers(cls, event: str, data: dict) -> None:
|
||||
"""Fire a single event to its registered handlers."""
|
||||
handlers = cls._handlers.get(event, [])
|
||||
data = dict(data) # Copy to avoid mutating caller's dict
|
||||
data['fire_event'] = cls.fire
|
||||
for handler in handlers:
|
||||
if handler in cls._disabled_handlers:
|
||||
continue
|
||||
try:
|
||||
handler(**data)
|
||||
cls._handler_failures.pop(handler, None) # Reset on success
|
||||
except Exception as e:
|
||||
logger.error(f"[TRIGGER] Handler error for {event}: {e}")
|
||||
count = cls._handler_failures.get(handler, 0) + 1
|
||||
cls._handler_failures[handler] = count
|
||||
if count >= cls._HANDLER_FAILURE_THRESHOLD:
|
||||
cls._disabled_handlers.add(handler)
|
||||
logger.error(
|
||||
f"[TRIGGER] Handler {getattr(handler, '__name__', handler)} "
|
||||
f"disabled after {count} consecutive failures"
|
||||
)
|
||||
else:
|
||||
logger.error(f"[TRIGGER] Handler error for {event}: {e}")
|
||||
|
||||
@classmethod
|
||||
def _drain_deferred(cls) -> None:
|
||||
|
||||
@@ -304,8 +304,8 @@ class TestReportError:
|
||||
mock_trigger.fire.assert_called_once()
|
||||
assert result["dispatched"] is True
|
||||
|
||||
def test_count_3_does_not_fire_event(self, monkeypatch):
|
||||
"""report_error does NOT fire event when count>=3 and is_new=False."""
|
||||
def test_count_3_fires_event(self, monkeypatch):
|
||||
"""report_error fires event at count=3 — handler decides dispatch via backoff."""
|
||||
reporter = _import_reporter()
|
||||
registry_report = _get_registry_report()
|
||||
registry_report.return_value = {
|
||||
@@ -324,11 +324,11 @@ class TestReportError:
|
||||
|
||||
result = reporter.report_error("ImportError", "No module foo", "FLOW")
|
||||
|
||||
mock_trigger.fire.assert_not_called()
|
||||
assert result["dispatched"] is False
|
||||
mock_trigger.fire.assert_called_once()
|
||||
assert result["dispatched"] is True
|
||||
|
||||
def test_count_5_does_not_fire_event(self, monkeypatch):
|
||||
"""report_error does NOT fire event for count=5."""
|
||||
def test_count_5_fires_event(self, monkeypatch):
|
||||
"""report_error fires event at count=5 — handler decides dispatch via backoff."""
|
||||
reporter = _import_reporter()
|
||||
registry_report = _get_registry_report()
|
||||
registry_report.return_value = {
|
||||
@@ -347,8 +347,8 @@ class TestReportError:
|
||||
|
||||
result = reporter.report_error("ImportError", "No module foo", "FLOW")
|
||||
|
||||
mock_trigger.fire.assert_not_called()
|
||||
assert result["dispatched"] is False
|
||||
mock_trigger.fire.assert_called_once()
|
||||
assert result["dispatched"] is True
|
||||
|
||||
def test_fire_event_false_never_fires(self, monkeypatch):
|
||||
"""report_error with fire_event=False never fires an event."""
|
||||
|
||||
Reference in New Issue
Block a user