feat(system): seedgo v2 operational, full system audit, 14/15 branches at 99%
Three days of intensive work bringing seedgo to full operational status and driving all branches through comprehensive standards compliance. Seedgo v2.0.0: - 22 checkers active (up from 20), standards pack fully operational - New introspection standard researched from Dev-Pass, FPLAN-0017 open - Bypass system for false positives (.seedgo config) - Standards query and audit commands fully functional System-wide audit (FPLAN-0016): - All 14 auditable branches at 99%+ compliance - CLI imports standardized across all branches (console from cli.apps.modules) - handle_command(command, args) → bool contract added to all modules - print_help() function naming fixed for checker pattern matching - Handler extraction: large modules split, file I/O moved to handler layer - New handlers created across ai_mail, backup, daemon, flow, skills, spawn, seedgo Branch-specific highlights: - ai_mail: email.py split 840→420 lines, 4 new handlers - flow: dplan_flow.py 688→591 lines, 4 new handlers - seedgo: massive restructure — standards moved to handlers/aipass_standards/, old standards/ tree removed, bypass system added, diagnostics module - commons: database module added, CLI imports fixed - skills: 5 handle_commands added, help function renamed - trigger: error reporter handler, handle_command routing - All branches: consistent architecture, clean drone routing Culture doc (CLAUDE.md) added — documents AIPass philosophy, identity, memory system, and collaboration principles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
09e759a8a4
commit
babedd9c64
@@ -1,23 +1,16 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# =================== AIPass ====================
|
||||
# Name: aggregate_central.py
|
||||
# Date: 2025-11-30
|
||||
# Version: 1.2.0
|
||||
# Category: flow/modules
|
||||
# CODE STANDARDS: Seedgo v3.0
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v1.2.0 (2025-11-30): Comprehensive sync fix - update branch statistics, recently_closed, and global_statistics
|
||||
# - v1.1.0 (2025-11-30): Fixed branch-level stale data - update branch_data["active_plans"] after validation
|
||||
# - v1.0.0 (2025-11-21): Initial creation - self-healing central aggregator
|
||||
# Description: Aggregate Central Plans Module
|
||||
# Version: 1.3.0
|
||||
# Created: 2025-11-30
|
||||
# Modified: 2025-11-30
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Aggregate Central Plans Module
|
||||
|
||||
Self-healing central aggregator for PLANS.central.json that validates file
|
||||
existence and rebuilds the active_plans list.
|
||||
Thin orchestrator for central plans aggregation.
|
||||
All business logic delegated to handlers/plan/aggregate_ops.py.
|
||||
|
||||
Features:
|
||||
- Validates all plans in branches.* sections have files on disk
|
||||
@@ -27,17 +20,6 @@ Features:
|
||||
- Updates statistics across all branches
|
||||
- Preserves unknown branch sections
|
||||
|
||||
Algorithm:
|
||||
1. Load PLANS.central.json
|
||||
2. For each branch section:
|
||||
- For each plan in active_plans:
|
||||
- Check if file_path exists on disk
|
||||
- If missing: find branch registry, mark plan closed, save registry
|
||||
3. Rebuild top-level active_plans from all valid branch plans
|
||||
4. Rebuild recently_closed from all branches (last 5)
|
||||
5. Update statistics
|
||||
6. Save PLANS.central.json
|
||||
|
||||
Usage:
|
||||
from aipass.flow.apps.modules.aggregate_central import aggregate_central
|
||||
success = aggregate_central()
|
||||
@@ -47,19 +29,20 @@ Standalone:
|
||||
python3 apps/modules/aggregate_central.py aggregate --heal
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Tuple, Optional
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
# Implementation handler
|
||||
from aipass.flow.apps.handlers.plan.aggregate_ops import aggregate_central_impl
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
# =============================================
|
||||
@@ -80,279 +63,15 @@ _REPO_ROOT = _find_repo_root()
|
||||
AI_CENTRAL_DIR = _REPO_ROOT / ".ai_central"
|
||||
CENTRAL_FILE = AI_CENTRAL_DIR / "PLANS.central.json"
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def _find_branch_registry(branch_path: Path, branch_name: str) -> Optional[Path]:
|
||||
"""Find the registry file for a branch
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch directory
|
||||
branch_name: Name of the branch
|
||||
|
||||
Returns:
|
||||
Path to registry file if found, None otherwise
|
||||
|
||||
Checks common patterns:
|
||||
- {branch_path}/flow_json/{branch}_registry.json
|
||||
- {branch_path}/{branch}_json/{branch}_registry.json
|
||||
- {branch_path}/registry.json
|
||||
"""
|
||||
if not branch_path.exists():
|
||||
return None
|
||||
|
||||
# Pattern 1: flow_json/flow_registry.json
|
||||
candidate = branch_path / "flow_json" / f"{branch_name}_registry.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Pattern 2: branch_json/branch_registry.json
|
||||
candidate = branch_path / f"{branch_name}_json" / f"{branch_name}_registry.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Pattern 3: registry.json
|
||||
candidate = branch_path / "registry.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _load_branch_registry(registry_path: Path) -> Dict[str, Any]:
|
||||
"""Load a branch registry file
|
||||
|
||||
Args:
|
||||
registry_path: Path to the registry file
|
||||
|
||||
Returns:
|
||||
Registry dict or empty structure on error
|
||||
"""
|
||||
try:
|
||||
with open(registry_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to load registry {registry_path}: {e}")
|
||||
return {"plans": {}, "next_number": 1}
|
||||
|
||||
|
||||
def _save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
|
||||
"""Save a branch registry file
|
||||
|
||||
Args:
|
||||
registry_path: Path to the registry file
|
||||
registry: Registry data to save
|
||||
|
||||
Returns:
|
||||
True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
with open(registry_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _extract_plan_number(plan_id: str) -> Optional[str]:
|
||||
"""Extract plan number from plan_id (e.g., 'FPLAN-0148' -> '0148')
|
||||
|
||||
Args:
|
||||
plan_id: Plan ID string (e.g., 'FPLAN-0148')
|
||||
|
||||
Returns:
|
||||
Plan number string or None if invalid format
|
||||
"""
|
||||
if not plan_id or not plan_id.startswith("FPLAN-"):
|
||||
return None
|
||||
return plan_id[6:] # Skip 'FPLAN-' prefix
|
||||
|
||||
|
||||
def _auto_close_plan(registry_path: Path, plan_id: str, branch_name: str) -> bool:
|
||||
"""Auto-close a plan in its branch registry
|
||||
|
||||
Args:
|
||||
registry_path: Path to the branch registry
|
||||
plan_id: Plan ID (e.g., 'PLAN0148')
|
||||
branch_name: Name of the branch for logging
|
||||
|
||||
Returns:
|
||||
True if plan was closed, False otherwise
|
||||
"""
|
||||
plan_num = _extract_plan_number(plan_id)
|
||||
if not plan_num:
|
||||
logger.warning(f"[{MODULE_NAME}] Invalid plan_id format: {plan_id}")
|
||||
return False
|
||||
|
||||
# Load registry
|
||||
registry = _load_branch_registry(registry_path)
|
||||
plans = registry.get("plans", {})
|
||||
|
||||
# Check if plan exists in registry
|
||||
if plan_num not in plans:
|
||||
logger.warning(f"[{MODULE_NAME}] {plan_id} not found in registry {registry_path}")
|
||||
return False
|
||||
|
||||
plan_info = plans[plan_num]
|
||||
|
||||
# Skip if already closed
|
||||
if plan_info.get("status") == "closed":
|
||||
logger.info(f"[{MODULE_NAME}] {plan_id} already closed in {branch_name}")
|
||||
return False
|
||||
|
||||
# Close the plan
|
||||
plan_info["status"] = "closed"
|
||||
plan_info["closed"] = datetime.now(timezone.utc).isoformat()
|
||||
plan_info["closed_reason"] = "auto_closed_missing_file"
|
||||
|
||||
# Save registry
|
||||
if _save_branch_registry(registry_path, registry):
|
||||
logger.info(f"[{MODULE_NAME}] SUCCESS: Auto-closed {plan_id} in {branch_name} - file not found")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to save registry after closing {plan_id}")
|
||||
return False
|
||||
|
||||
|
||||
def _validate_and_heal_branch(branch_name: str, branch_data: Dict[str, Any],
|
||||
heal: bool = True) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Validate plans in a branch and heal missing files
|
||||
|
||||
Args:
|
||||
branch_name: Name of the branch
|
||||
branch_data: Branch data from PLANS.central.json
|
||||
heal: If True, auto-close plans with missing files
|
||||
|
||||
Returns:
|
||||
Tuple of (valid_active_plans, all_closed_plans)
|
||||
"""
|
||||
branch_path = Path(branch_data.get("branch_path", ""))
|
||||
active_plans = branch_data.get("active_plans", [])
|
||||
recently_closed = branch_data.get("recently_closed", [])
|
||||
|
||||
valid_active = []
|
||||
healed_plans = []
|
||||
|
||||
# Find branch registry for healing
|
||||
registry_path = None
|
||||
if heal and branch_path.exists():
|
||||
registry_path = _find_branch_registry(branch_path, branch_name)
|
||||
|
||||
# Validate active plans
|
||||
for plan in active_plans:
|
||||
file_path = Path(plan.get("file_path", ""))
|
||||
plan_id = plan.get("plan_id", plan.get("plan", ""))
|
||||
|
||||
if file_path.exists():
|
||||
valid_active.append(plan)
|
||||
else:
|
||||
logger.info(f"[{MODULE_NAME}] Missing file for {plan_id} in {branch_name}: {file_path}")
|
||||
|
||||
# Attempt to heal
|
||||
if heal and registry_path:
|
||||
if _auto_close_plan(registry_path, plan_id, branch_name):
|
||||
# Add to healed plans for recently_closed
|
||||
healed_plan = plan.copy()
|
||||
healed_plan["status"] = "closed"
|
||||
healed_plan["closed"] = datetime.now(timezone.utc).isoformat()
|
||||
healed_plan["closed_reason"] = "auto_closed_missing_file"
|
||||
healed_plans.append(healed_plan)
|
||||
else:
|
||||
if heal:
|
||||
logger.warning(f"[{MODULE_NAME}] Cannot heal {plan_id} - registry not found for {branch_name}")
|
||||
|
||||
# Combine recently_closed with healed plans
|
||||
all_closed = recently_closed + healed_plans
|
||||
|
||||
return valid_active, all_closed
|
||||
|
||||
|
||||
def _load_central() -> Dict[str, Any]:
|
||||
"""Load PLANS.central.json
|
||||
|
||||
Returns:
|
||||
Central data or empty structure if file doesn't exist
|
||||
"""
|
||||
if not CENTRAL_FILE.exists():
|
||||
return {
|
||||
"generated_at": "",
|
||||
"active_plans": [],
|
||||
"recently_closed": [],
|
||||
"statistics": {
|
||||
"active_count": 0,
|
||||
"total_closed": 0,
|
||||
"recently_closed_included": 0
|
||||
},
|
||||
"branches": {},
|
||||
"global_statistics": {
|
||||
"total_active": 0,
|
||||
"total_closed": 0,
|
||||
"branches_reporting": 0
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(CENTRAL_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to load {CENTRAL_FILE}: {e}")
|
||||
return {
|
||||
"generated_at": "",
|
||||
"active_plans": [],
|
||||
"recently_closed": [],
|
||||
"statistics": {
|
||||
"active_count": 0,
|
||||
"total_closed": 0,
|
||||
"recently_closed_included": 0
|
||||
},
|
||||
"branches": {},
|
||||
"global_statistics": {
|
||||
"total_active": 0,
|
||||
"total_closed": 0,
|
||||
"branches_reporting": 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _save_central(central_data: Dict[str, Any]) -> bool:
|
||||
"""Save PLANS.central.json
|
||||
|
||||
Args:
|
||||
central_data: Central data to save
|
||||
|
||||
Returns:
|
||||
True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(CENTRAL_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(central_data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to save {CENTRAL_FILE}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN AGGREGATION FUNCTION
|
||||
# MAIN AGGREGATION FUNCTION (thin orchestrator)
|
||||
# =============================================
|
||||
|
||||
def aggregate_central(heal: bool = True) -> bool:
|
||||
"""Aggregate and validate central plans
|
||||
"""Aggregate and validate central plans (thin orchestrator)
|
||||
|
||||
Algorithm:
|
||||
1. Load PLANS.central.json
|
||||
2. For each branch in branches.*:
|
||||
- Validate all active_plans have files on disk
|
||||
- If heal=True and file missing: auto-close in branch registry
|
||||
- Collect valid active plans and all closed plans
|
||||
3. Rebuild top-level active_plans (sorted by created, newest first)
|
||||
4. Rebuild top-level recently_closed (last 5, sorted by closed, newest first)
|
||||
5. Update statistics
|
||||
6. Save PLANS.central.json
|
||||
Delegates all business logic to aggregate_ops handler.
|
||||
|
||||
Args:
|
||||
heal: If True, auto-close plans with missing files in their registries
|
||||
@@ -360,124 +79,29 @@ def aggregate_central(heal: bool = True) -> bool:
|
||||
Returns:
|
||||
True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[{MODULE_NAME}] Starting central aggregation")
|
||||
|
||||
# Load central file
|
||||
central_data = _load_central()
|
||||
branches = central_data.get("branches", {})
|
||||
|
||||
if not branches:
|
||||
logger.info(f"[{MODULE_NAME}] No branches found in PLANS.central.json")
|
||||
return True
|
||||
|
||||
# Track all active and closed plans across branches
|
||||
all_active = []
|
||||
all_closed = []
|
||||
|
||||
# Process each branch
|
||||
for branch_name, branch_data in branches.items():
|
||||
logger.info(f"[{MODULE_NAME}] Processing branch: {branch_name}")
|
||||
|
||||
# Validate and heal
|
||||
valid_active, closed_plans = _validate_and_heal_branch(
|
||||
branch_name, branch_data, heal
|
||||
)
|
||||
|
||||
# Update branch-level active_plans with validated list
|
||||
branch_data["active_plans"] = valid_active
|
||||
|
||||
# Update branch-level recently_closed (sorted, newest first)
|
||||
branch_recently_closed = sorted(
|
||||
closed_plans,
|
||||
key=lambda x: x.get("closed", ""),
|
||||
reverse=True
|
||||
)[:5]
|
||||
branch_data["recently_closed"] = branch_recently_closed
|
||||
|
||||
# Update branch-level statistics to match validated arrays
|
||||
branch_data["statistics"] = {
|
||||
"active_count": len(valid_active),
|
||||
"total_closed": len(closed_plans),
|
||||
"recently_closed_included": len(branch_recently_closed)
|
||||
}
|
||||
|
||||
# Add branch name to each plan for identification
|
||||
for plan in valid_active:
|
||||
if "branch" not in plan:
|
||||
plan["branch"] = branch_name
|
||||
all_active.append(plan)
|
||||
|
||||
for plan in closed_plans:
|
||||
if "branch" not in plan:
|
||||
plan["branch"] = branch_name
|
||||
all_closed.append(plan)
|
||||
|
||||
# Sort active by created date (newest first)
|
||||
all_active.sort(
|
||||
key=lambda x: x.get("created", ""),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Sort closed by closed date (newest first) and limit to last 5
|
||||
all_closed.sort(
|
||||
key=lambda x: x.get("closed", ""),
|
||||
reverse=True
|
||||
)
|
||||
recently_closed = all_closed[:5]
|
||||
|
||||
# Update top-level arrays
|
||||
central_data["active_plans"] = all_active
|
||||
central_data["recently_closed"] = recently_closed
|
||||
|
||||
# Update top-level statistics
|
||||
central_data["statistics"] = {
|
||||
"active_count": len(all_active),
|
||||
"total_closed": len(all_closed),
|
||||
"recently_closed_included": len(recently_closed)
|
||||
}
|
||||
|
||||
# Update global_statistics (aggregated from all branches)
|
||||
central_data["global_statistics"] = {
|
||||
"total_active": len(all_active),
|
||||
"total_closed": len(all_closed),
|
||||
"branches_reporting": len([
|
||||
b for b in branches.values()
|
||||
if b.get("active_plans") or b.get("recently_closed")
|
||||
])
|
||||
}
|
||||
|
||||
# Update generated_at timestamp
|
||||
central_data["generated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Save central file
|
||||
if _save_central(central_data):
|
||||
logger.info(f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed")
|
||||
|
||||
# Fire trigger event
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('central_aggregated',
|
||||
active_count=len(all_active),
|
||||
closed_count=len(recently_closed),
|
||||
branches_count=len(branches))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to save central file")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Aggregation failed: {e}")
|
||||
return False
|
||||
return aggregate_central_impl(
|
||||
heal=heal,
|
||||
central_file=CENTRAL_FILE,
|
||||
central_dir=AI_CENTRAL_DIR,
|
||||
)
|
||||
|
||||
|
||||
# =============================================
|
||||
# DISPLAY FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("aggregate_central Module")
|
||||
console.print("Thin orchestrator for central plans aggregation and self-healing validation")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/plan/")
|
||||
console.print(" - aggregate_ops.py (aggregate_central_impl — aggregation and healing logic)")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help information for aggregate_central module"""
|
||||
console.print()
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: close_plan.py - PLAN closure module with registry cleanup
|
||||
# Date: 2025-11-25
|
||||
# Version: 3.4.0
|
||||
# Category: flow/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v3.4.0 (2026-02-25): FIX orphaned .md files - idempotency check now cleans up stranded files on re-close
|
||||
# - v3.3.0 (2026-02-14): FIX race condition - close_all spawns ONE background process instead of N
|
||||
# - v3.2.0 (2026-02-14): Auto-confirm by default, step-by-step progress, per-step error handling (Seedgo standards)
|
||||
# - v3.1.0 (2026-02-14): FIX timeout - summary/archive now truly async via subprocess (was synchronous despite comments)
|
||||
# - v3.0.0 (2026-02-14): DECOUPLE close from archive - close always succeeds, archive is non-blocking
|
||||
# - v2.4.0 (2026-01-30): FIX close_all EOF error - handle non-interactive stdin in bulk close
|
||||
# - v2.3.0 (2025-11-25): RE-ADDED template deletion - empty templates now deleted instead of archived
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Seedgo v3.0 compliant (imports, architecture, error handling)
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: close_plan.py
|
||||
# Description: PLAN closure module with registry cleanup
|
||||
# Version: 3.6.0
|
||||
# Created: 2025-11-25
|
||||
# Modified: 2025-11-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Close PLAN Module
|
||||
|
||||
Thin orchestrator for plan closure workflow.
|
||||
All business logic delegated to handlers.
|
||||
Module handles all display output.
|
||||
|
||||
Usage:
|
||||
From flow.py: flow close <number>
|
||||
@@ -32,17 +20,15 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
# External: Prax logger
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.prax import logger
|
||||
|
||||
# JSON handler for operation tracking
|
||||
from aipass.flow.apps.handlers.json import json_handler
|
||||
@@ -71,10 +57,12 @@ from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_lo
|
||||
from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central
|
||||
from aipass.flow.apps.handlers.dashboard.push_branch_dashboard import push_flow_to_branch_dashboard
|
||||
|
||||
|
||||
# Internal: Memory bank template check (lightweight, no API calls)
|
||||
from aipass.flow.apps.handlers.mbank.process import is_template_content
|
||||
|
||||
# Internal: Close operations handler (implementation)
|
||||
from aipass.flow.apps.handlers.plan.close_ops import close_plan_impl, close_all_plans_impl
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
# =============================================
|
||||
@@ -82,6 +70,73 @@ from aipass.flow.apps.handlers.mbank.process import is_template_content
|
||||
MODULE_NAME = "close_plan"
|
||||
|
||||
|
||||
# =============================================
|
||||
# DISPLAY HELPERS
|
||||
# =============================================
|
||||
|
||||
def _display_messages(messages: List[Dict[str, Any]]):
|
||||
"""Render handler result messages to console
|
||||
|
||||
Args:
|
||||
messages: List of message dicts from handler with type/text keys
|
||||
"""
|
||||
for msg in messages:
|
||||
msg_type = msg.get("type", "")
|
||||
|
||||
if msg_type == "error":
|
||||
error_text = msg.get("text", "general")
|
||||
plan_num = msg.get("plan_num", "")
|
||||
details = msg.get("details", None)
|
||||
console.print(format_plan_error(error_text, plan_num, details=details))
|
||||
|
||||
elif msg_type == "warning":
|
||||
console.print(f"[yellow]{msg['text']}[/yellow]")
|
||||
|
||||
elif msg_type == "dim":
|
||||
console.print(f"[dim]{msg['text']}[/dim]")
|
||||
|
||||
elif msg_type == "step":
|
||||
console.print(f"[dim]{msg['text']}[/dim]")
|
||||
|
||||
elif msg_type == "success":
|
||||
console.print(f"[green]{msg['text']}[/green]")
|
||||
|
||||
elif msg_type == "error_text":
|
||||
console.print(f"[red]{msg['text']}[/red]")
|
||||
|
||||
elif msg_type == "header":
|
||||
console.print(format_plan_deletion_header(msg["plan_key"], msg["plan_info"]))
|
||||
|
||||
elif msg_type == "cancelled":
|
||||
console.print(format_deletion_cancelled())
|
||||
|
||||
elif msg_type == "close_success":
|
||||
console.print(format_plan_deletion_success(msg["plan_key"]))
|
||||
|
||||
elif msg_type == "plan_list":
|
||||
console.print(f"\n[bold yellow]Found {msg['count']} open plan(s) to close:[/bold yellow]")
|
||||
for plan in msg.get("plans", []):
|
||||
console.print(f" * FPLAN-{plan['plan_num']}: {plan['subject']}")
|
||||
|
||||
elif msg_type == "confirm_warning":
|
||||
console.print(f"\n[bold red]WARNING: This will close all {msg['count']} plans![/bold red]")
|
||||
|
||||
elif msg_type == "closing_all":
|
||||
console.print(f"\n[bold]Closing all {msg['count']} plan(s)...[/bold]")
|
||||
console.print("-" * 60)
|
||||
|
||||
elif msg_type == "closing_single":
|
||||
console.print(f"\n[dim]Closing FPLAN-{msg['plan_num']}...[/dim]")
|
||||
|
||||
elif msg_type == "close_all_summary":
|
||||
console.print("\n" + "=" * 60)
|
||||
console.print("[bold green]CLOSE ALL COMPLETE[/bold green]")
|
||||
console.print(f" * Successfully closed: {msg['success_count']}")
|
||||
console.print(f" * Failed to close: {msg['failure_count']}")
|
||||
console.print(f" * Total processed: {msg['total']}")
|
||||
console.print("=" * 60 + "\n")
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
@@ -96,6 +151,7 @@ def print_introspection():
|
||||
console.print()
|
||||
|
||||
console.print(" [cyan]handlers/plan/[/cyan]")
|
||||
console.print(" [dim]- close_ops.py (implementation)[/dim]")
|
||||
console.print(" [dim]- get_open_plans.py[/dim]")
|
||||
console.print(" [dim]- command_parser.py[/dim]")
|
||||
console.print(" [dim]- confirmation.py[/dim]")
|
||||
@@ -119,22 +175,7 @@ def print_introspection():
|
||||
console.print()
|
||||
|
||||
# =============================================
|
||||
# HELPERS
|
||||
# =============================================
|
||||
|
||||
def _spawn_background_runner():
|
||||
"""Spawn post_close_runner.py as a fully detached background process"""
|
||||
bg_runner = FLOW_ROOT / "apps" / "modules" / "post_close_runner.py"
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(bg_runner)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLOSE PLAN WORKFLOW
|
||||
# CLOSE PLAN WORKFLOW (thin orchestrator)
|
||||
# =============================================
|
||||
|
||||
def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bool = False, spawn_background: bool = True) -> bool:
|
||||
@@ -150,6 +191,7 @@ def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bo
|
||||
- File ops: file_ops handler
|
||||
- Confirmation: confirmation handler
|
||||
- Display: display handler
|
||||
- Close implementation: close_ops handler
|
||||
|
||||
Args:
|
||||
plan_num: Plan number (e.g., "0001" or "1" or "42") - required if all_plans=False
|
||||
@@ -161,185 +203,36 @@ def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bo
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Handle --all flag
|
||||
if all_plans:
|
||||
return close_all_plans(confirm)
|
||||
result = close_plan_impl(
|
||||
plan_num=plan_num,
|
||||
confirm=confirm,
|
||||
all_plans=all_plans,
|
||||
spawn_background=spawn_background,
|
||||
# Inject dependencies
|
||||
normalize_plan_number=normalize_plan_number,
|
||||
load_registry=load_registry,
|
||||
save_registry=save_registry,
|
||||
validate_plan_exists=validate_plan_exists,
|
||||
confirm_plan_deletion=confirm_plan_deletion,
|
||||
is_template_content=is_template_content,
|
||||
update_dashboard_local=update_dashboard_local,
|
||||
push_to_plans_central=push_to_plans_central,
|
||||
push_flow_to_branch_dashboard=push_flow_to_branch_dashboard,
|
||||
close_all_plans_fn=close_all_plans,
|
||||
)
|
||||
|
||||
# Single plan closure
|
||||
if not plan_num:
|
||||
logger.warning(f"[{MODULE_NAME}] Plan number required for single plan closure")
|
||||
console.print(format_plan_error("invalid_number", ""))
|
||||
return False
|
||||
# Handle dict result from handler
|
||||
if isinstance(result, dict):
|
||||
_display_messages(result.get("messages", []))
|
||||
return result.get("success", False)
|
||||
|
||||
try:
|
||||
# --- Internal validation (fast, no progress display) ---
|
||||
|
||||
# 1. VALIDATE: Normalize plan number (handler)
|
||||
plan_key = normalize_plan_number(plan_num)
|
||||
|
||||
# 2. LOAD DATA: Get registry (service)
|
||||
registry = load_registry()
|
||||
|
||||
# 3. VALIDATE: Check plan exists (handler)
|
||||
exists, error_msg = validate_plan_exists(plan_key, registry)
|
||||
if not exists:
|
||||
logger.warning(f"[{MODULE_NAME}] {error_msg}")
|
||||
console.print(format_plan_error("not_found", plan_key))
|
||||
return False
|
||||
|
||||
plan_info = registry["plans"][plan_key]
|
||||
plan_file = Path(plan_info.get("file_path", ""))
|
||||
|
||||
# 4. IDEMPOTENCY CHECK: Prevent double-closing (with orphan cleanup)
|
||||
if plan_info['status'] == 'closed':
|
||||
closed_date = plan_info.get('closed', 'unknown')
|
||||
|
||||
# Check if .md file is orphaned on disk (registry-closed but file never moved)
|
||||
if plan_file.exists():
|
||||
console.print(f"[yellow]FPLAN-{plan_key} already closed on {closed_date} — orphaned .md file detected[/yellow]")
|
||||
console.print(f"[dim] Cleaning up: moving {plan_file.name} to processed_plans/[/dim]")
|
||||
try:
|
||||
from aipass.flow.apps.handlers.mbank.process import archive_plan
|
||||
if archive_plan(plan_file):
|
||||
logger.info(f"[{MODULE_NAME}] Cleaned up orphaned file for FPLAN-{plan_key}: {plan_file}")
|
||||
console.print(f"[green] Orphaned file archived successfully[/green]")
|
||||
else:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to archive orphaned file for FPLAN-{plan_key}: {plan_file}")
|
||||
console.print(f"[red] Failed to move orphaned file — manual cleanup required[/red]")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Error cleaning orphaned file for FPLAN-{plan_key}: {e}")
|
||||
console.print(f"[red] Error during cleanup: {e}[/red]")
|
||||
return True
|
||||
|
||||
console.print(f"[yellow]FPLAN-{plan_key} already closed on {closed_date}[/yellow]")
|
||||
console.print("[dim]Nothing to do - plan is already archived[/dim]")
|
||||
return False
|
||||
|
||||
# --- Step 1/5: Template check (may fast-delete) ---
|
||||
console.print(f"[dim][1/5][/dim] Checking template status...")
|
||||
try:
|
||||
with open(plan_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if is_template_content(content):
|
||||
console.print(f"[yellow] FPLAN-{plan_key} is empty template - fast-deleting (not archiving)[/yellow]")
|
||||
|
||||
# Delete the file
|
||||
plan_file.unlink()
|
||||
logger.info(f"[{MODULE_NAME}] Deleted empty template file: {plan_file}")
|
||||
|
||||
# Remove from registry
|
||||
del registry["plans"][plan_key]
|
||||
save_registry(registry)
|
||||
logger.info(f"[{MODULE_NAME}] Removed FPLAN-{plan_key} from registry")
|
||||
|
||||
console.print(f"[green] Empty template deleted - FPLAN-{plan_key} removed from system[/green]")
|
||||
return True
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Template check - file not found: {e}")
|
||||
console.print(f"[yellow] Plan file not found, continuing with registry close[/yellow]")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Template check failed: {e}")
|
||||
console.print(f"[yellow] Could not check template status, continuing with normal close[/yellow]")
|
||||
|
||||
# DISPLAY: Show plan info header (handler)
|
||||
console.print(format_plan_deletion_header(plan_key, plan_info))
|
||||
|
||||
# CONFIRM: Ask user only if explicitly requested (--confirm/--interactive)
|
||||
if confirm:
|
||||
if not confirm_plan_deletion(plan_key):
|
||||
console.print(format_deletion_cancelled())
|
||||
logger.info(f"[{MODULE_NAME}] Closure cancelled by user for PLAN{plan_key}")
|
||||
return False
|
||||
|
||||
# --- Step 2/5: Mark as closed ---
|
||||
console.print(f"[dim][2/5][/dim] Marking plan as closed...")
|
||||
try:
|
||||
# CRITICAL: Close ALWAYS succeeds from this point. Archive is non-blocking.
|
||||
plan_info['status'] = 'closed'
|
||||
plan_info['closed'] = datetime.now(timezone.utc).isoformat()
|
||||
save_registry(registry)
|
||||
logger.info(f"[{MODULE_NAME}] Marked FPLAN-{plan_key} as closed")
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to mark plan as closed: {e}")
|
||||
console.print(f"[red] Failed to update registry: {e}[/red]")
|
||||
return False
|
||||
|
||||
# --- Step 3/5: Background processing ---
|
||||
if spawn_background:
|
||||
console.print(f"[dim][3/5][/dim] Starting background processing...")
|
||||
try:
|
||||
_spawn_background_runner()
|
||||
logger.info(f"[{MODULE_NAME}] Spawned background post-processing for FPLAN-{plan_key}")
|
||||
console.print(f"[dim] Summary generation and archival running in background[/dim]")
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Background runner not found: {e}")
|
||||
console.print(f"[yellow] Background runner not found - will retry on next close[/yellow]")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to spawn background post-processing: {e}")
|
||||
console.print(f"[yellow] Background archival failed to start - will retry on next close[/yellow]")
|
||||
else:
|
||||
console.print(f"[dim][3/5][/dim] Background processing deferred (batch mode)")
|
||||
|
||||
# --- Step 4/5: Update dashboards ---
|
||||
console.print(f"[dim][4/5][/dim] Updating dashboards...")
|
||||
try:
|
||||
dashboard_success = update_dashboard_local()
|
||||
central_success = push_to_plans_central()
|
||||
|
||||
# Log dashboard update results (3-tier: modules log, handlers don't)
|
||||
if not dashboard_success:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json")
|
||||
if not central_success:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json")
|
||||
|
||||
# Push flow section to branch's dashboard via write-through
|
||||
plan_location = plan_info.get("location", "")
|
||||
if plan_location:
|
||||
branch_dashboard_success = push_flow_to_branch_dashboard(Path(plan_location))
|
||||
if not branch_dashboard_success:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to push flow section to branch dashboard at {plan_location}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Dashboard update error: {e}")
|
||||
console.print(f"[yellow] Dashboard update failed (non-critical): {e}[/yellow]")
|
||||
|
||||
# --- Step 5/5: Done ---
|
||||
console.print(f"[dim][5/5][/dim] Finalizing...")
|
||||
console.print(format_plan_deletion_success(plan_key))
|
||||
|
||||
# Append to branch's CLOSED_PLANS.local.json
|
||||
try:
|
||||
from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans
|
||||
append_to_closed_plans(plan_key, plan_info, plan_file.parent)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] CLOSED_PLANS update failed (non-critical): {e}")
|
||||
|
||||
# Fire trigger event for plan closure (optional - trigger module may not be available)
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('plan_closed', plan_number=plan_key, location=str(plan_file.parent))
|
||||
except ImportError:
|
||||
logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Trigger fire failed (non-critical): {e}")
|
||||
|
||||
return True
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Invalid plan number: {plan_num}: {e}")
|
||||
console.print(format_plan_error("invalid_number", plan_num))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Unexpected error closing plan: {e}")
|
||||
console.print(format_plan_error("general", details=str(e)))
|
||||
return False
|
||||
# Fallback for bool result
|
||||
return bool(result)
|
||||
|
||||
|
||||
def close_all_plans(confirm: bool = False) -> bool:
|
||||
"""
|
||||
Close all open plans in one operation
|
||||
Close all open plans in one operation (thin orchestrator)
|
||||
|
||||
Args:
|
||||
confirm: Whether to ask for bulk confirmation (default False, auto-confirms)
|
||||
@@ -347,86 +240,19 @@ def close_all_plans(confirm: bool = False) -> bool:
|
||||
Returns:
|
||||
True if at least one plan closed successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get all open plans (handler)
|
||||
open_plans = get_open_plans()
|
||||
result = close_all_plans_impl(
|
||||
confirm=confirm,
|
||||
get_open_plans=get_open_plans,
|
||||
close_plan_fn=close_plan,
|
||||
)
|
||||
|
||||
if not open_plans:
|
||||
console.print("\n[yellow]No open plans to close[/yellow]\n")
|
||||
logger.info(f"[{MODULE_NAME}] close_all: No open plans found")
|
||||
return False
|
||||
# Handle dict result from handler
|
||||
if isinstance(result, dict):
|
||||
_display_messages(result.get("messages", []))
|
||||
return result.get("success", False)
|
||||
|
||||
# Show what will be closed
|
||||
console.print(f"\n[bold yellow]Found {len(open_plans)} open plan(s) to close:[/bold yellow]")
|
||||
for plan_num, plan_info in open_plans:
|
||||
subject = plan_info.get("subject", "No subject")
|
||||
console.print(f" • FPLAN-{plan_num}: {subject}")
|
||||
|
||||
# Confirm bulk close
|
||||
if confirm:
|
||||
console.print(f"\n[bold red]WARNING: This will close all {len(open_plans)} plans![/bold red]")
|
||||
|
||||
# Auto-confirm in non-interactive environments (autonomous workflows)
|
||||
if not sys.stdin.isatty():
|
||||
console.print("[dim]Non-interactive mode: auto-confirming[/dim]")
|
||||
response = "yes"
|
||||
else:
|
||||
try:
|
||||
response = input("Type 'yes' to confirm: ").strip().lower()
|
||||
except EOFError:
|
||||
# Handle EOF when stdin is not available
|
||||
console.print("[dim]EOF detected: auto-confirming[/dim]")
|
||||
response = "yes"
|
||||
|
||||
if response != "yes":
|
||||
console.print("\n[yellow]Close all cancelled[/yellow]\n")
|
||||
logger.info(f"[{MODULE_NAME}] close_all cancelled by user")
|
||||
return False
|
||||
|
||||
console.print(f"\n[bold]Closing all {len(open_plans)} plan(s)...[/bold]")
|
||||
console.print("─" * 60)
|
||||
|
||||
# Close each plan
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
for plan_num, plan_info in open_plans:
|
||||
console.print(f"\n[dim]Closing FPLAN-{plan_num}...[/dim]")
|
||||
|
||||
# Call close_plan with spawn_background=False to avoid race condition
|
||||
success = close_plan(plan_num=plan_num, confirm=False, all_plans=False, spawn_background=False)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
failure_count += 1
|
||||
|
||||
# Spawn ONE background process for all closed plans
|
||||
if success_count > 0:
|
||||
try:
|
||||
_spawn_background_runner()
|
||||
logger.info(f"[{MODULE_NAME}] Spawned single background process for {success_count} closed plan(s)")
|
||||
console.print(f"\n[dim]Background processing started for {success_count} plan(s)[/dim]")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to spawn background post-processing: {e}")
|
||||
console.print(f"\n[yellow]Background processing failed to start - will retry on next close[/yellow]")
|
||||
|
||||
# Summary
|
||||
console.print("\n" + "═" * 60)
|
||||
console.print("[bold green]CLOSE ALL COMPLETE[/bold green]")
|
||||
console.print(f" • Successfully closed: {success_count}")
|
||||
console.print(f" • Failed to close: {failure_count}")
|
||||
console.print(f" • Total processed: {len(open_plans)}")
|
||||
console.print("═" * 60 + "\n")
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] close_all completed: {success_count} success, {failure_count} failures")
|
||||
return success_count > 0
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error in close_all: {e}"
|
||||
logger.error(f"[{MODULE_NAME}] {error_msg}")
|
||||
console.print(f"\n[bold red]ERROR:[/bold red] {error_msg}\n")
|
||||
return False
|
||||
# Fallback for bool result
|
||||
return bool(result)
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: create_plan.py - PLAN creation module with location awareness
|
||||
# Date: 2025-11-16
|
||||
# Version: 1.0.0
|
||||
# Category: flow/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-16): Refactored from archive_temp, handler-based architecture
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Seedgo v3.0 compliant (imports, architecture, error handling)
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: create_plan.py
|
||||
# Description: PLAN creation module with location awareness
|
||||
# Version: 1.2.0
|
||||
# Created: 2025-11-16
|
||||
# Modified: 2025-11-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Create PLAN Module - Thin Orchestrator
|
||||
@@ -20,30 +13,28 @@ Orchestrates plan creation workflow by delegating to handlers.
|
||||
Module contains NO business logic - only workflow coordination.
|
||||
|
||||
Workflow:
|
||||
1. Parse arguments → command_parser handler
|
||||
2. Load registry → registry handlers
|
||||
3. Auto-cleanup → plan handlers
|
||||
4. Validate location → plan handlers
|
||||
5. Calculate paths → plan handlers
|
||||
6. Get template → template handlers
|
||||
7. Create file → plan handlers
|
||||
8. Update registry → plan handlers
|
||||
9. Update dashboards → dashboard handlers
|
||||
10. Display results → display handlers
|
||||
1. Parse arguments -> command_parser handler
|
||||
2. Load registry -> registry handlers
|
||||
3. Auto-cleanup -> plan handlers
|
||||
4. Validate location -> plan handlers
|
||||
5. Calculate paths -> plan handlers
|
||||
6. Get template -> template handlers
|
||||
7. Create file -> plan handlers
|
||||
8. Update registry -> plan handlers
|
||||
9. Update dashboards -> dashboard handlers
|
||||
10. Display results -> display handlers
|
||||
|
||||
Usage:
|
||||
From flow.py: flow plan create [location] [subject] [template]
|
||||
Standalone: python3 create_plan.py [location] [subject] [template]
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Tuple, List
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
# External: Prax logger
|
||||
@@ -76,6 +67,9 @@ from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_lo
|
||||
from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central
|
||||
from aipass.flow.apps.handlers.dashboard.push_branch_dashboard import push_flow_to_branch_dashboard
|
||||
|
||||
# Implementation handler
|
||||
from aipass.flow.apps.handlers.plan.create_ops import create_plan_impl
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
@@ -98,6 +92,7 @@ def print_introspection():
|
||||
console.print()
|
||||
|
||||
console.print(" [cyan]handlers/plan/[/cyan]")
|
||||
console.print(" [dim]- create_ops.py (implementation)[/dim]")
|
||||
console.print(" [dim]- command_parser.py[/dim]")
|
||||
console.print(" [dim]- auto_cleanup.py[/dim]")
|
||||
console.print(" [dim]- resolve_location.py[/dim]")
|
||||
@@ -147,19 +142,9 @@ def print_help():
|
||||
console.print(" python3 create_plan.py create @flow \"Research task\" master")
|
||||
console.print()
|
||||
|
||||
# =============================================
|
||||
# HELPERS
|
||||
# =============================================
|
||||
|
||||
def _slugify_subject(subject: str) -> str:
|
||||
"""Sanitize subject for filename: lowercase, underscores, max 40 chars."""
|
||||
slug = re.sub(r'[^\w\s-]', '', subject.lower())
|
||||
slug = re.sub(r'[\s-]+', '_', slug)
|
||||
return slug.strip('_')[:40]
|
||||
|
||||
|
||||
# =============================================
|
||||
# ORCHESTRATION WORKFLOWS (No business logic)
|
||||
# ORCHESTRATION WORKFLOWS (thin wrappers)
|
||||
# =============================================
|
||||
|
||||
def create_plan(
|
||||
@@ -168,22 +153,10 @@ def create_plan(
|
||||
template_type: str = "default"
|
||||
) -> Tuple[bool, int, str, str, str]:
|
||||
"""
|
||||
Orchestrate plan creation workflow
|
||||
Orchestrate plan creation workflow (thin orchestrator)
|
||||
|
||||
THIN ORCHESTRATOR - delegates all business logic to handlers.
|
||||
This function only coordinates the workflow and passes data between handlers.
|
||||
|
||||
Workflow Steps:
|
||||
1. Load registry → registry handler
|
||||
2. Auto-cleanup → plan handler
|
||||
3. Resolve location → plan handler
|
||||
4. Calculate relative path → plan handler
|
||||
5. Get template → template handler
|
||||
6. Create file → plan handler
|
||||
7. Build registry entry → plan handler
|
||||
8. Update registry → registry handler
|
||||
9. Update dashboards → dashboard handlers
|
||||
10. Log and return results
|
||||
Delegates all business logic to create_ops handler.
|
||||
Module handles all display output from handler messages.
|
||||
|
||||
Args:
|
||||
location: Target directory for plan (@folder syntax supported)
|
||||
@@ -193,103 +166,42 @@ def create_plan(
|
||||
Returns:
|
||||
(success, plan_number, location_description, template_type, error_message)
|
||||
"""
|
||||
try:
|
||||
# STEP 1: Load registry
|
||||
registry = load_registry()
|
||||
result = create_plan_impl(
|
||||
location=location,
|
||||
subject=subject,
|
||||
template_type=template_type,
|
||||
# Inject dependencies
|
||||
ecosystem_root=ECOSYSTEM_ROOT,
|
||||
load_registry=load_registry,
|
||||
save_registry=save_registry,
|
||||
auto_close_orphaned_plans=auto_close_orphaned_plans,
|
||||
resolve_plan_location=resolve_plan_location,
|
||||
calculate_relative_location=calculate_relative_location,
|
||||
get_template=get_template,
|
||||
create_plan_file=create_plan_file,
|
||||
build_plan_registry_entry=build_plan_registry_entry,
|
||||
display_plan_created=display_plan_created,
|
||||
update_dashboard_local=update_dashboard_local,
|
||||
push_to_plans_central=push_to_plans_central,
|
||||
push_flow_to_branch_dashboard=push_flow_to_branch_dashboard,
|
||||
)
|
||||
|
||||
# STEP 2: Auto-cleanup orphaned plans
|
||||
registry, auto_closed_count = auto_close_orphaned_plans(registry)
|
||||
if auto_closed_count > 0:
|
||||
save_registry(registry)
|
||||
console.print(f"[dim][AUTO-CLEANUP] Closed {auto_closed_count} orphaned plan(s)[/dim]")
|
||||
# Handler returns 6-tuple: (success, num, loc, tmpl, error, messages)
|
||||
if len(result) == 6:
|
||||
ok, num, loc, tmpl, error, messages = result
|
||||
# Module handles display
|
||||
for msg in messages:
|
||||
msg_type = msg.get("type", "")
|
||||
if msg_type == "dim":
|
||||
console.print(f"[dim]{msg['text']}[/dim]")
|
||||
elif msg_type == "warning":
|
||||
console.print(f"[yellow]{msg['text']}[/yellow]")
|
||||
elif msg_type == "display":
|
||||
console.print(msg["text"])
|
||||
return ok, num, loc, tmpl, error
|
||||
|
||||
# STEP 3: Get next plan number
|
||||
NEXT_NUM = registry["next_number"]
|
||||
|
||||
# STEP 4: Resolve location (@folder syntax support)
|
||||
success, target_dir, error_msg = resolve_plan_location(location, ECOSYSTEM_ROOT)
|
||||
if not success:
|
||||
return False, 0, "", "", error_msg
|
||||
|
||||
# STEP 5: Calculate relative path for display
|
||||
RELATIVE_LOCATION = calculate_relative_location(target_dir, ECOSYSTEM_ROOT)
|
||||
|
||||
# STEP 6: Build plan file path (FPLAN-XXXX_topic_slug_YYYY-MM-DD.md)
|
||||
topic_slug = _slugify_subject(subject)
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
if topic_slug:
|
||||
PLAN_FILE = target_dir / f"FPLAN-{NEXT_NUM:04d}_{topic_slug}_{date_str}.md"
|
||||
else:
|
||||
PLAN_FILE = target_dir / f"FPLAN-{NEXT_NUM:04d}_{date_str}.md"
|
||||
|
||||
# STEP 7: Get template content
|
||||
try:
|
||||
CONTENT = get_template(
|
||||
template_type,
|
||||
number=NEXT_NUM,
|
||||
location=RELATIVE_LOCATION,
|
||||
subject=subject
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to load template '{template_type}': {e}"
|
||||
logger.error(f"[{MODULE_NAME}] {error_msg}")
|
||||
return False, 0, "", "", error_msg
|
||||
|
||||
# STEP 8: Create plan file
|
||||
success, error_msg = create_plan_file(PLAN_FILE, CONTENT)
|
||||
if not success:
|
||||
return False, 0, "", "", error_msg
|
||||
|
||||
# STEP 9: Build registry entry
|
||||
if "plans" not in registry:
|
||||
registry["plans"] = {}
|
||||
|
||||
registry["plans"][f"{NEXT_NUM:04d}"] = build_plan_registry_entry(
|
||||
NEXT_NUM, target_dir, RELATIVE_LOCATION, subject, PLAN_FILE, template_type
|
||||
)
|
||||
registry["next_number"] = NEXT_NUM + 1
|
||||
|
||||
# STEP 10: Save updated registry
|
||||
if not save_registry(registry):
|
||||
error_msg = "Failed to save registry after plan creation"
|
||||
logger.error(f"[{MODULE_NAME}] {error_msg}")
|
||||
console.print(f"[yellow][WARNING] {error_msg}[/yellow]")
|
||||
|
||||
# STEP 11: Update dashboards (3-tier: modules log, handlers don't)
|
||||
dashboard_success = update_dashboard_local()
|
||||
central_success = push_to_plans_central()
|
||||
|
||||
# Log dashboard update results
|
||||
if not dashboard_success:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json")
|
||||
if not central_success:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json")
|
||||
|
||||
# STEP 11b: Push flow section to branch's dashboard via write-through
|
||||
branch_dashboard_success = push_flow_to_branch_dashboard(target_dir)
|
||||
if not branch_dashboard_success:
|
||||
console.print(f"[dim]⚠ No branch dashboard at {target_dir} — no branch is tracking this plan[/dim]")
|
||||
|
||||
# STEP 12: Log success
|
||||
logger.info(f"[{MODULE_NAME}] Created FPLAN-{NEXT_NUM:04d} in {RELATIVE_LOCATION}")
|
||||
|
||||
# Display success messages
|
||||
display_msg = display_plan_created(NEXT_NUM, RELATIVE_LOCATION, subject, template_type)
|
||||
console.print(display_msg)
|
||||
|
||||
# Fire trigger event for plan creation
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('plan_created', plan_number=NEXT_NUM, location=RELATIVE_LOCATION, subject=subject)
|
||||
except ImportError:
|
||||
pass # Trigger not available, silent fallback
|
||||
|
||||
return True, NEXT_NUM, RELATIVE_LOCATION, template_type, ""
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error creating plan: {e}"
|
||||
logger.error(f"[{MODULE_NAME}] {error_msg}")
|
||||
return False, 0, "", "", error_msg
|
||||
# Fallback for 5-tuple (backward compatibility)
|
||||
return result
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
@@ -299,9 +211,9 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
THIN ORCHESTRATOR - delegates to handlers for all operations.
|
||||
|
||||
Workflow:
|
||||
1. Parse arguments → command_parser handler
|
||||
1. Parse arguments -> command_parser handler
|
||||
2. Execute create_plan workflow
|
||||
3. Display results → display handler
|
||||
3. Display results -> display handler
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
|
||||
@@ -1,52 +1,26 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: dev_flow.py - Plan management module (thin orchestrator)
|
||||
# Date: 2025-12-02
|
||||
# Version: 4.0.0
|
||||
# Category: Module
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v4.0.0 (2026-02-19): Multi-type (DPLAN/BPLAN), --type flag, @ branch resolution
|
||||
# - v3.0.0 (2026-02-18): Tags, filters, registry, dashboard per FPLAN-0355
|
||||
# - v2.0.0 (2025-12-02): Refactored to thin orchestrator - all logic in handlers
|
||||
# - v1.0.0 (2025-12-02): Initial version - plan create/list/status commands
|
||||
#
|
||||
# CONNECTS:
|
||||
# - handlers/plan/ (all plan handlers)
|
||||
# - registry.py (plan tracking)
|
||||
# - dashboard.py (DASHBOARD.local.json + DEVPULSE.central.json push)
|
||||
# - BRANCH_REGISTRY.json (@ resolution)
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Modules orchestrate, handlers implement (3-tier architecture)
|
||||
# - handle_command() interface for drone routing
|
||||
# - Module does the logging, handlers return errors
|
||||
# - CLI services for output (no print())
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: dplan_flow.py
|
||||
# Description: Plan management module (thin orchestrator)
|
||||
# Version: 5.0.0
|
||||
# Created: 2025-12-02
|
||||
# Modified: 2025-12-02
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Plan Management Module - Thin Orchestrator
|
||||
|
||||
Routes commands to handlers in handlers/plan/.
|
||||
Routes commands to handlers in handlers/dplan/.
|
||||
Manages numbered, dated planning documents (DPLAN, BPLAN) in dev_planning/.
|
||||
Supports @ branch resolution for creating plans in other branches.
|
||||
"""
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
AIPASS_ROOT = Path.home() / "aipass_core"
|
||||
sys.path.insert(0, str(AIPASS_ROOT))
|
||||
sys.path.insert(0, str(Path.home()))
|
||||
from typing import List
|
||||
|
||||
# Infrastructure imports (module does the logging)
|
||||
from prax.apps.modules.logger import system_logger as logger
|
||||
from cli.apps.modules import console, header, success, error
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error
|
||||
|
||||
# Handler imports
|
||||
from aipass_os.dev_central.devpulse.apps.handlers.plan.create import create_plan
|
||||
@@ -64,66 +38,72 @@ from aipass_os.dev_central.devpulse.apps.handlers.plan.registry import (
|
||||
from aipass_os.dev_central.devpulse.apps.handlers.plan.dashboard import push_all as _push_dashboard_raw
|
||||
from aipass_os.dev_central.devpulse.apps.handlers.dashboard.operations import write_section
|
||||
|
||||
# Local handlers (file I/O extracted from this module)
|
||||
from aipass.flow.apps.handlers.dplan.branch_resolve import resolve_branch_target as _resolve_branch
|
||||
from aipass.flow.apps.handlers.dplan.closed_plans_registry import append_closed_dplan
|
||||
from aipass.flow.apps.handlers.dplan.log_setup import prepare_log_file
|
||||
from aipass.flow.apps.handlers.dplan.background_spawn import spawn_post_close
|
||||
|
||||
|
||||
def push_dashboard(activity: str | None = None) -> dict:
|
||||
"""Module-level wrapper: injects write_section into handler."""
|
||||
return _push_dashboard_raw(activity=activity, write_fn=write_section)
|
||||
|
||||
# =============================================================================
|
||||
# CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
BRANCH_REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# @ BRANCH RESOLUTION
|
||||
# @ BRANCH RESOLUTION (thin wrapper around handler)
|
||||
# =============================================================================
|
||||
|
||||
def resolve_branch_target(branch_ref: str) -> Optional[Path]:
|
||||
"""
|
||||
Resolve @branch reference to a filesystem path via BRANCH_REGISTRY.json.
|
||||
|
||||
Args:
|
||||
branch_ref: Branch reference like "@vera" or "@team_1"
|
||||
|
||||
Returns:
|
||||
Path to the branch directory, or None if not found
|
||||
"""
|
||||
name = branch_ref.lstrip("@").upper()
|
||||
|
||||
if not BRANCH_REGISTRY_PATH.exists():
|
||||
logger.warning(f"[dev_flow] BRANCH_REGISTRY.json not found at {BRANCH_REGISTRY_PATH}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(BRANCH_REGISTRY_PATH, "r", encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("name", "").upper() == name:
|
||||
branch_path = Path(branch["path"])
|
||||
if branch_path.exists():
|
||||
return branch_path
|
||||
else:
|
||||
logger.warning(f"[dev_flow] Branch path does not exist: {branch_path}")
|
||||
return None
|
||||
|
||||
logger.warning(f"[dev_flow] Branch '{name}' not found in registry")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[dev_flow] Failed to read branch registry: {e}")
|
||||
def resolve_branch_target(branch_ref: str):
|
||||
"""Resolve @branch reference — delegates to handler, logs result."""
|
||||
result = _resolve_branch(branch_ref)
|
||||
if not result["success"]:
|
||||
logger.warning(f"[dev_flow] {result['error']}")
|
||||
return None
|
||||
return result["path"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE INTERFACE
|
||||
# =============================================================================
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("dplan_flow Module")
|
||||
console.print("Plan management orchestrator — routes plan commands to handlers")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/plan/ (via aipass_os.dev_central.devpulse)")
|
||||
console.print(" - create.py (create_plan — create new plans)")
|
||||
console.print(" - list.py (list_plans — list plans with filters)")
|
||||
console.print(" - status.py (get_status_summary — plan status aggregation)")
|
||||
console.print(" - display.py (show_help — help text display)")
|
||||
console.print(" - close.py (close_plan, get_open_plans — close plans)")
|
||||
console.print(" - counter.py (VALID_PLAN_TYPES — plan type definitions)")
|
||||
console.print(" - registry.py (register_plan, update_plan_status — registry ops)")
|
||||
console.print(" - dashboard.py (push_all — dashboard updates)")
|
||||
console.print()
|
||||
console.print(" handlers/dashboard/ (via aipass_os.dev_central.devpulse)")
|
||||
console.print(" - operations.py (write_section — dashboard section writer)")
|
||||
console.print()
|
||||
console.print(" handlers/dplan/")
|
||||
console.print(" - branch_resolve.py (resolve_branch_target — @ branch resolution)")
|
||||
console.print(" - closed_plans_registry.py (append_closed_dplan — closed plan tracking)")
|
||||
console.print(" - log_setup.py (prepare_log_file — log file preparation)")
|
||||
console.print(" - background_spawn.py (spawn_post_close — background archival)")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display D-PLAN help text — thin wrapper around handler's show_help()."""
|
||||
header("D-PLAN - Development Planning")
|
||||
console.print(show_help())
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle D-PLAN commands - routes to handlers
|
||||
Handle D-PLAN commands - routes to handlers.
|
||||
|
||||
Args:
|
||||
command: Command to execute ('plan')
|
||||
@@ -135,16 +115,9 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
if command != 'plan':
|
||||
return False
|
||||
|
||||
# Handle --help flag
|
||||
if args and args[0] == '--help':
|
||||
header("D-PLAN - Development Planning")
|
||||
console.print(show_help())
|
||||
return True
|
||||
|
||||
# Parse subcommand
|
||||
if not args:
|
||||
header("D-PLAN - Development Planning")
|
||||
console.print(show_help())
|
||||
# Handle --help / -h flag
|
||||
if not args or (args[0] in ('--help', '-h')):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
@@ -171,8 +144,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
|
||||
def _handle_create(args: List[str]) -> bool:
|
||||
"""Orchestrate plan creation - delegates to handler"""
|
||||
# Handle --help flag
|
||||
if args and args[0] == '--help':
|
||||
if args and args[0] in ('--help', '-h'):
|
||||
console.print("\n[bold]USAGE:[/bold]")
|
||||
console.print(" plan create \"topic name\" [--type type] [--tag tag] [--dir subdir] [@branch]")
|
||||
console.print("\n[bold]OPTIONS:[/bold]")
|
||||
@@ -200,7 +172,6 @@ def _handle_create(args: List[str]) -> bool:
|
||||
target_path = None
|
||||
target_branch_name = None
|
||||
|
||||
# Check for --dir flag
|
||||
if '--dir' in args:
|
||||
dir_idx = args.index('--dir')
|
||||
if dir_idx + 1 < len(args):
|
||||
@@ -209,7 +180,6 @@ def _handle_create(args: List[str]) -> bool:
|
||||
error("--dir requires a subdirectory name")
|
||||
return True
|
||||
|
||||
# Check for --tag flag
|
||||
if '--tag' in args:
|
||||
tag_idx = args.index('--tag')
|
||||
if tag_idx + 1 < len(args):
|
||||
@@ -221,7 +191,6 @@ def _handle_create(args: List[str]) -> bool:
|
||||
error("--tag requires a tag name")
|
||||
return True
|
||||
|
||||
# Check for --type flag
|
||||
if '--type' in args:
|
||||
type_idx = args.index('--type')
|
||||
if type_idx + 1 < len(args):
|
||||
@@ -234,7 +203,7 @@ def _handle_create(args: List[str]) -> bool:
|
||||
error("--type requires a plan type (dplan, bplan)")
|
||||
return True
|
||||
|
||||
# Check for @branch target or pre-resolved path (drone resolves @vera to /path)
|
||||
# Check for @branch target or pre-resolved path
|
||||
for arg in args[1:]:
|
||||
if arg.startswith("@") and not arg.startswith("--"):
|
||||
target_branch_name = arg
|
||||
@@ -244,7 +213,6 @@ def _handle_create(args: List[str]) -> bool:
|
||||
return True
|
||||
break
|
||||
elif arg.startswith("/") and Path(arg).exists():
|
||||
# Drone pre-resolved @branch to absolute path
|
||||
target_branch_name = f"@{Path(arg).name}"
|
||||
target_path = Path(arg)
|
||||
break
|
||||
@@ -262,10 +230,8 @@ def _handle_create(args: List[str]) -> bool:
|
||||
error(f"Failed to create plan: {err}")
|
||||
return True
|
||||
|
||||
# Log success (module does logging)
|
||||
logger.info(f"[dev_flow] Created {prefix}-{result['plan_number']:03d}: {result['filename']}")
|
||||
|
||||
# Log cache warning if any
|
||||
if result.get('cache_warning'):
|
||||
logger.warning(f"[dev_flow] {result['cache_warning']}")
|
||||
|
||||
@@ -284,7 +250,6 @@ def _handle_create(args: List[str]) -> bool:
|
||||
except Exception as e:
|
||||
logger.warning(f"[dev_flow] Failed to register plan: {e}")
|
||||
|
||||
# Push dashboard update with activity context
|
||||
try:
|
||||
activity = f"DPLAN-{result['plan_number']:03d} created ({result['topic'][:30]})"
|
||||
push_dashboard(activity=activity)
|
||||
@@ -307,7 +272,6 @@ def _handle_create(args: List[str]) -> bool:
|
||||
|
||||
def _handle_list(args: List[str]) -> bool:
|
||||
"""Orchestrate plan listing with optional filters"""
|
||||
# Parse filter flags
|
||||
filter_tag = None
|
||||
filter_status = None
|
||||
filter_type = None
|
||||
@@ -327,7 +291,6 @@ def _handle_list(args: List[str]) -> bool:
|
||||
if type_idx + 1 < len(args):
|
||||
filter_type = args[type_idx + 1].lower()
|
||||
|
||||
# Delegate to handler (pass type filter for scanning)
|
||||
plans, err = list_plans(filter_type=filter_type)
|
||||
|
||||
if err:
|
||||
@@ -335,13 +298,11 @@ def _handle_list(args: List[str]) -> bool:
|
||||
error(f"Failed to list plans: {err}")
|
||||
return True
|
||||
|
||||
# Apply tag/status filters
|
||||
if filter_tag:
|
||||
plans = [p for p in plans if p.get("tag") == filter_tag]
|
||||
if filter_status:
|
||||
plans = [p for p in plans if p.get("status") == filter_status]
|
||||
|
||||
# Display results
|
||||
console.print()
|
||||
title = "Plans"
|
||||
if filter_type:
|
||||
@@ -366,12 +327,10 @@ def _handle_list(args: List[str]) -> bool:
|
||||
tag_display = f"({p['tag']})" if p.get("tag") else ""
|
||||
prefix = p.get("prefix", "DPLAN")
|
||||
|
||||
# Get summary from cache or description
|
||||
summary = get_summary(p["number"])
|
||||
if not summary:
|
||||
summary = p.get("description", "")
|
||||
|
||||
# Format: icon PREFIX-NNN | Topic | (tag) | summary
|
||||
line = f" {status_icon} [cyan]{prefix}-{p['number']:03d}[/cyan] | {p['topic'][:30]:<30}"
|
||||
if tag_display:
|
||||
line += f" | [dim]{tag_display}[/dim]"
|
||||
@@ -389,14 +348,12 @@ def _handle_list(args: List[str]) -> bool:
|
||||
|
||||
def _handle_status(args: List[str]) -> bool:
|
||||
"""Orchestrate status display - delegates to handler"""
|
||||
# Parse --type filter
|
||||
filter_type = None
|
||||
if '--type' in args:
|
||||
type_idx = args.index('--type')
|
||||
if type_idx + 1 < len(args):
|
||||
filter_type = args[type_idx + 1].lower()
|
||||
|
||||
# Delegate to handler
|
||||
status_counts, total, err = get_status_summary(filter_type=filter_type)
|
||||
|
||||
if err:
|
||||
@@ -404,7 +361,6 @@ def _handle_status(args: List[str]) -> bool:
|
||||
error(f"Failed to get status: {err}")
|
||||
return True
|
||||
|
||||
# Display results
|
||||
console.print()
|
||||
title = "Plan Status"
|
||||
if filter_type:
|
||||
@@ -412,14 +368,14 @@ def _handle_status(args: List[str]) -> bool:
|
||||
header(title)
|
||||
console.print()
|
||||
|
||||
console.print(f" [yellow]📋 Planning:[/yellow] {status_counts['planning']}")
|
||||
console.print(f" [blue]🔄 In Progress:[/blue] {status_counts['in_progress']}")
|
||||
console.print(f" [green]✅ Ready:[/green] {status_counts['ready']}")
|
||||
console.print(f" [dim]✓ Complete:[/dim] {status_counts['complete']}")
|
||||
console.print(f" [red]❌ Abandoned:[/red] {status_counts['abandoned']}")
|
||||
console.print(f" [yellow]Planning:[/yellow] {status_counts['planning']}")
|
||||
console.print(f" [blue]In Progress:[/blue] {status_counts['in_progress']}")
|
||||
console.print(f" [green]Ready:[/green] {status_counts['ready']}")
|
||||
console.print(f" [dim]Complete:[/dim] {status_counts['complete']}")
|
||||
console.print(f" [red]Abandoned:[/red] {status_counts['abandoned']}")
|
||||
|
||||
if status_counts["unknown"] > 0:
|
||||
console.print(f" [dim]? Unknown:[/dim] {status_counts['unknown']}")
|
||||
console.print(f" [dim]Unknown:[/dim] {status_counts['unknown']}")
|
||||
|
||||
console.print()
|
||||
console.print(f"[dim]Total: {total} plans[/dim]")
|
||||
@@ -430,10 +386,7 @@ def _handle_status(args: List[str]) -> bool:
|
||||
|
||||
def _handle_close(args: List[str]) -> bool:
|
||||
"""Orchestrate plan closing - delegates to handler, spawns background archival"""
|
||||
import subprocess
|
||||
|
||||
# Handle --help flag
|
||||
if args and args[0] == '--help':
|
||||
if args and args[0] in ('--help', '-h'):
|
||||
console.print("\n[bold]USAGE:[/bold]")
|
||||
console.print(" plan close <number>")
|
||||
console.print(" plan close --all")
|
||||
@@ -443,7 +396,6 @@ def _handle_close(args: List[str]) -> bool:
|
||||
console.print(" plan close --all\n")
|
||||
return True
|
||||
|
||||
# Handle --all flag
|
||||
if args and args[0] == '--all':
|
||||
return _handle_close_all()
|
||||
|
||||
@@ -451,7 +403,6 @@ def _handle_close(args: List[str]) -> bool:
|
||||
error("Usage: plan close <number>")
|
||||
return True
|
||||
|
||||
# Parse plan number
|
||||
plan_num, err = normalize_plan_number(args[0])
|
||||
if err:
|
||||
logger.warning(f"[dev_flow] {err}")
|
||||
@@ -474,7 +425,6 @@ def _handle_close(args: List[str]) -> bool:
|
||||
try:
|
||||
update_plan_status(plan_num, "complete")
|
||||
|
||||
# Generate and cache summary from description
|
||||
plan_file = Path(result.get('plan_file', ''))
|
||||
if plan_file.exists():
|
||||
summary = generate_description_summary(plan_file)
|
||||
@@ -483,49 +433,29 @@ def _handle_close(args: List[str]) -> bool:
|
||||
except Exception as e:
|
||||
logger.warning(f"[dev_flow] Registry update failed: {e}")
|
||||
|
||||
# Push dashboard update with activity context
|
||||
# Push dashboard update
|
||||
try:
|
||||
activity = f"DPLAN-{plan_num:03d} closed ({result['topic'][:30]})"
|
||||
push_dashboard(activity=activity)
|
||||
except Exception as e:
|
||||
logger.warning(f"[dev_flow] Dashboard push failed: {e}")
|
||||
|
||||
# Append to branch's CLOSED_PLANS.local.json
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
_closed_plans_path = Path("/home/aipass/aipass_os/dev_central/CLOSED_PLANS.local.json")
|
||||
_entry = {
|
||||
"plan_id": f"DPLAN-{plan_num:03d}",
|
||||
"type": "DPLAN",
|
||||
"subject": result.get("topic", ""),
|
||||
"date_closed": _dt.now().strftime("%Y-%m-%d"),
|
||||
"location": "dev_central"
|
||||
}
|
||||
if _closed_plans_path.exists():
|
||||
_data = json.loads(_closed_plans_path.read_text())
|
||||
else:
|
||||
_data = {"closed_plans": []}
|
||||
# Skip if already exists
|
||||
if not any(p["plan_id"] == _entry["plan_id"] for p in _data["closed_plans"]):
|
||||
_data["closed_plans"].insert(0, _entry)
|
||||
_closed_plans_path.write_text(json.dumps(_data, indent=2) + "\n")
|
||||
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with DPLAN-{plan_num:03d}")
|
||||
except Exception as _e:
|
||||
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {_e}")
|
||||
# Append to CLOSED_PLANS.local.json via handler
|
||||
reg_result = append_closed_dplan(plan_num, result.get("topic", ""))
|
||||
if reg_result["success"]:
|
||||
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with {reg_result['plan_id']}")
|
||||
else:
|
||||
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {reg_result['error']}")
|
||||
|
||||
# Step 2/3: Spawn background processing
|
||||
console.print(f"[dim][2/3][/dim] Starting background archival...")
|
||||
try:
|
||||
bg_runner = Path(__file__).parent / "post_close_runner.py"
|
||||
log_file = Path.home() / "aipass_os" / "logs" / "post_close_runner.log"
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_fh = open(log_file, "a")
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(bg_runner)],
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
start_new_session=True
|
||||
)
|
||||
log_result = prepare_log_file("post_close_runner.log")
|
||||
if not log_result["success"]:
|
||||
raise RuntimeError(log_result["error"])
|
||||
spawn_result = spawn_post_close(log_file_handle=log_result["file_handle"])
|
||||
if not spawn_result["success"]:
|
||||
raise RuntimeError(spawn_result["error"])
|
||||
logger.info(f"[dev_flow] Spawned background post-processing for DPLAN-{plan_num:03d}")
|
||||
console.print(f"[dim] Memory Bank archival running in background[/dim]")
|
||||
except Exception as e:
|
||||
@@ -545,8 +475,6 @@ def _handle_close(args: List[str]) -> bool:
|
||||
|
||||
def _handle_close_all() -> bool:
|
||||
"""Close all open plans"""
|
||||
import subprocess
|
||||
|
||||
open_plans = get_open_plans()
|
||||
|
||||
if not open_plans:
|
||||
@@ -571,39 +499,23 @@ def _handle_close_all() -> bool:
|
||||
logger.info(f"[dev_flow] Closed DPLAN-{p['number']:03d}")
|
||||
console.print(f"[green] Marked as complete[/green]")
|
||||
|
||||
# Update registry
|
||||
try:
|
||||
update_plan_status(p['number'], "complete")
|
||||
except Exception as reg_err:
|
||||
logger.warning(f"[dev_flow] Registry update failed for DPLAN-{p['number']:03d}: {reg_err}")
|
||||
|
||||
# Append to branch's CLOSED_PLANS.local.json
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
_closed_plans_path = Path("/home/aipass/aipass_os/dev_central/CLOSED_PLANS.local.json")
|
||||
_entry = {
|
||||
"plan_id": f"DPLAN-{p['number']:03d}",
|
||||
"type": "DPLAN",
|
||||
"subject": result.get("topic", ""),
|
||||
"date_closed": _dt.now().strftime("%Y-%m-%d"),
|
||||
"location": "dev_central"
|
||||
}
|
||||
if _closed_plans_path.exists():
|
||||
_data = json.loads(_closed_plans_path.read_text())
|
||||
else:
|
||||
_data = {"closed_plans": []}
|
||||
if not any(ep["plan_id"] == _entry["plan_id"] for ep in _data["closed_plans"]):
|
||||
_data["closed_plans"].insert(0, _entry)
|
||||
_closed_plans_path.write_text(json.dumps(_data, indent=2) + "\n")
|
||||
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with DPLAN-{p['number']:03d}")
|
||||
except Exception as _e:
|
||||
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {_e}")
|
||||
# Append to CLOSED_PLANS.local.json via handler
|
||||
reg_result = append_closed_dplan(p['number'], result.get("topic", ""))
|
||||
if reg_result["success"]:
|
||||
logger.info(f"[dev_flow] Updated CLOSED_PLANS registry with {reg_result['plan_id']}")
|
||||
else:
|
||||
logger.warning(f"[dev_flow] CLOSED_PLANS update failed (non-critical): {reg_result['error']}")
|
||||
else:
|
||||
failure_count += 1
|
||||
logger.warning(f"[dev_flow] Failed to close DPLAN-{p['number']:03d}: {err}")
|
||||
console.print(f"[red] Failed: {err}[/red]")
|
||||
|
||||
# Push dashboard update with activity context
|
||||
# Push dashboard
|
||||
try:
|
||||
activity = f"{success_count} plan(s) closed (batch)"
|
||||
push_dashboard(activity=activity)
|
||||
@@ -613,24 +525,20 @@ def _handle_close_all() -> bool:
|
||||
# Spawn ONE background process for all closed plans
|
||||
if success_count > 0:
|
||||
try:
|
||||
bg_runner = Path(__file__).parent / "post_close_runner.py"
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(bg_runner)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
spawn_result = spawn_post_close()
|
||||
if not spawn_result["success"]:
|
||||
raise RuntimeError(spawn_result["error"])
|
||||
logger.info(f"[dev_flow] Spawned background processing for {success_count} closed plan(s)")
|
||||
console.print(f"\n[dim]Background processing started for {success_count} plan(s)[/dim]")
|
||||
except Exception as e:
|
||||
logger.warning(f"[dev_flow] Failed to spawn background processing: {e}")
|
||||
console.print(f"\n[yellow]Background processing failed to start[/yellow]")
|
||||
|
||||
console.print("\n" + "═" * 60)
|
||||
console.print("\n" + "=" * 60)
|
||||
console.print("[bold green]CLOSE ALL COMPLETE[/bold green]")
|
||||
console.print(f" - Successfully closed: {success_count}")
|
||||
console.print(f" - Failed: {failure_count}")
|
||||
console.print("═" * 60 + "\n")
|
||||
console.print("=" * 60 + "\n")
|
||||
|
||||
return True
|
||||
|
||||
@@ -665,18 +573,14 @@ def _handle_sync() -> bool:
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Show introspection when run without arguments
|
||||
if len(sys.argv) == 1:
|
||||
console.print(print_introspection())
|
||||
sys.exit(0)
|
||||
|
||||
# Handle help flag
|
||||
if sys.argv[1] in ['--help', '-h', 'help']:
|
||||
header("D-PLAN - Development Planning")
|
||||
console.print(show_help())
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
# Route command: plan create "topic" -> handle_command("plan", ["create", "topic"])
|
||||
subcommand = sys.argv[1]
|
||||
remaining_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
#!/home/aipass/.venv/bin/python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: post_close_runner.py - Background post-close processing for DPLANs
|
||||
# Date: 2026-02-18
|
||||
# Version: 1.0.0
|
||||
# Category: devpulse/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-18): Initial version - adapted from Flow's post_close_runner.py
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Seed v3.0 compliant (imports, architecture, error handling)
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: dplan_post_close_runner.py
|
||||
# Description: Background post-close processing for DPLANs
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-02-18
|
||||
# Modified: 2026-02-18
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Post-Close Background Runner for DPLANs
|
||||
@@ -22,10 +14,6 @@ Called by dev_flow.py via subprocess.Popen so the close command returns fast.
|
||||
|
||||
Uses a lock file to prevent concurrent execution - if another instance is
|
||||
already running, this one exits silently.
|
||||
|
||||
Note: This is a background utility script, not a command-routable module.
|
||||
It has no handle_command() or --help because it is never invoked by users
|
||||
or drone directly - only by dev_flow.py via subprocess.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -38,8 +26,9 @@ DEVPULSE_ROOT = Path.home() / "aipass_os" / "dev_central" / "devpulse"
|
||||
sys.path.insert(0, str(AIPASS_ROOT))
|
||||
sys.path.insert(0, str(Path.home()))
|
||||
|
||||
# External: Prax logger
|
||||
from prax.apps.modules.logger import system_logger as logger
|
||||
# External: CLI console (Rich display) and Prax logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
MODULE_NAME = "dplan_post_close_runner"
|
||||
|
||||
@@ -48,6 +37,44 @@ LOCK_FILE = DEVPULSE_ROOT / ".post_close_runner.lock"
|
||||
from aipass_os.dev_central.devpulse.apps.handlers.mbank.process import process_closed_plans
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point.
|
||||
|
||||
This module is a background utility runner, not a user-facing command.
|
||||
It responds to 'dplan_post_close' for drone routing compatibility
|
||||
and supports --help / -h for introspection.
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
if command != "dplan_post_close":
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
# Run the post-close processing directly (foreground)
|
||||
if not _acquire_lock():
|
||||
console.print("[yellow]Another instance is already running[/yellow]")
|
||||
return True
|
||||
|
||||
try:
|
||||
result = process_closed_plans()
|
||||
console.print(f"[green]Processing complete:[/green] {result.get('processed', 0)} processed, {result.get('errors', 0)} errors")
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
||||
console.print(f"[red]Processing failed: {e}[/red]")
|
||||
finally:
|
||||
_release_lock()
|
||||
|
||||
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():
|
||||
@@ -71,7 +98,35 @@ def _release_lock():
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to release lock file: {e}")
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("dplan_post_close_runner Module")
|
||||
console.print("Background post-close processing for DPLANs — runs Memory Bank archival")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/mbank/ (via aipass_os.dev_central.devpulse)")
|
||||
console.print(" - process.py (process_closed_plans — scan and archive closed plans)")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display help for this background runner."""
|
||||
console.print(f"Usage: python {Path(__file__).name}")
|
||||
console.print()
|
||||
console.print("Post-Close Background Runner for DPLANs")
|
||||
console.print("Runs Memory Bank archival as a background process.")
|
||||
console.print("Called by dev_flow.py via subprocess — not intended for direct use.")
|
||||
console.print()
|
||||
console.print("Options:")
|
||||
console.print(" -h, --help Show this help message")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if '--help' in sys.argv or '-h' in sys.argv:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
if not _acquire_lock():
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: list_plans.py - PLAN listing module with filtering
|
||||
# Date: 2025-11-21
|
||||
# Version: 1.0.0
|
||||
# Category: flow/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2025-11-21): Initial implementation, handler-based architecture
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Seedgo v3.0 compliant (imports, architecture, error handling)
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: list_plans.py
|
||||
# Description: PLAN listing module with filtering
|
||||
# Version: 1.1.0
|
||||
# Created: 2025-11-21
|
||||
# Modified: 2025-11-21
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
List PLAN Module - Thin Orchestrator
|
||||
|
||||
Orchestrates plan listing workflow by delegating to handlers.
|
||||
Module contains NO business logic - only workflow coordination.
|
||||
Module contains NO business logic - only workflow coordination and display.
|
||||
|
||||
Workflow:
|
||||
1. Parse arguments → command_parser handler
|
||||
2. Load registry → registry handlers
|
||||
3. Get statistics → registry handlers
|
||||
4. Filter plans by status
|
||||
5. Format and display results
|
||||
1. Parse arguments
|
||||
2. Call list_ops handler for data
|
||||
3. Display results via console
|
||||
|
||||
Usage:
|
||||
From flow.py: flow plan list [filter]
|
||||
@@ -42,7 +33,7 @@ from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
# External: Prax logger
|
||||
@@ -65,6 +56,9 @@ from aipass.flow.apps.handlers.plan.display import (
|
||||
format_statistics_summary
|
||||
)
|
||||
|
||||
# Implementation handler
|
||||
from aipass.flow.apps.handlers.plan.list_ops import list_plans_impl
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
# =============================================
|
||||
@@ -81,15 +75,15 @@ def print_introspection():
|
||||
console.print("[bold cyan]list_plans Module[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
# List handlers this module actually imports/uses
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print()
|
||||
|
||||
# List handlers this module actually imports/uses
|
||||
console.print(" [cyan]handlers/registry/[/cyan]")
|
||||
console.print(" [dim]- load_registry.py[/dim]")
|
||||
console.print(" [dim]- statistics.py[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan]handlers/plan/[/cyan]")
|
||||
console.print(" [dim]- list_ops.py (implementation)[/dim]")
|
||||
console.print(" [dim]- display.py[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -136,10 +130,8 @@ def list_plans(filter_type: str = "open") -> bool:
|
||||
"""
|
||||
Orchestrate plan listing workflow (thin orchestrator)
|
||||
|
||||
Delegates all business logic to handlers:
|
||||
- Registry loading: load_registry handler
|
||||
- Statistics: get_registry_statistics handler
|
||||
- Display: format functions above
|
||||
Delegates all business logic to list_ops handler.
|
||||
Module handles all display output.
|
||||
|
||||
Args:
|
||||
filter_type: Filter plans by status ("open", "closed", "all")
|
||||
@@ -147,53 +139,37 @@ def list_plans(filter_type: str = "open") -> bool:
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# STEP 1: Load registry (handler)
|
||||
registry = load_registry()
|
||||
|
||||
# STEP 2: Get plans
|
||||
plans = registry.get("plans", {})
|
||||
|
||||
if not plans:
|
||||
console.print("[yellow]No plans found in registry[/yellow]")
|
||||
logger.info(f"[{MODULE_NAME}] No plans in registry")
|
||||
return True # Not an error, just empty
|
||||
|
||||
# STEP 3: Determine filter
|
||||
if filter_type == "all":
|
||||
filter_status = None
|
||||
else:
|
||||
filter_status = filter_type # "open" or "closed"
|
||||
|
||||
# STEP 4: Format and display plans
|
||||
formatted_list = format_plans_list(plans, filter_status)
|
||||
console.print(formatted_list)
|
||||
|
||||
# STEP 5: Get and display statistics
|
||||
stats = get_registry_statistics(registry)
|
||||
summary = format_statistics_summary(stats)
|
||||
console.print(summary)
|
||||
|
||||
# STEP 6: Log success
|
||||
logger.info(f"[{MODULE_NAME}] Listed plans (filter: {filter_type})")
|
||||
result = list_plans_impl(
|
||||
filter_type=filter_type,
|
||||
load_registry=load_registry,
|
||||
get_registry_statistics=get_registry_statistics,
|
||||
format_plans_list=format_plans_list,
|
||||
format_statistics_summary=format_statistics_summary,
|
||||
)
|
||||
|
||||
# Module handles display
|
||||
if result.get("empty") and result.get("success"):
|
||||
console.print("[yellow]No plans found in registry[/yellow]")
|
||||
return True
|
||||
|
||||
except BrokenPipeError:
|
||||
# Pipe closed by reader (e.g. automated subprocesses, head)
|
||||
# Not a real error - command likely completed
|
||||
logger.info(f"[{MODULE_NAME}] Broken pipe (stdout closed early)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error listing plans: {e}"
|
||||
logger.error(f"[{MODULE_NAME}] {error_msg}")
|
||||
if not result.get("success"):
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
try:
|
||||
console.print(f"[red]ERROR: {error_msg}[/red]")
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Display formatted results
|
||||
try:
|
||||
console.print(result["formatted_list"])
|
||||
console.print(result["formatted_stats"])
|
||||
except BrokenPipeError:
|
||||
# Pipe closed by reader - not a real error
|
||||
logger.info(f"[{MODULE_NAME}] Broken pipe (stdout closed early)")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: post_close_runner.py - Background post-close processing
|
||||
# Date: 2026-02-14
|
||||
# Version: 1.1.0
|
||||
# Category: flow/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-02-14): Add lock file to prevent concurrent execution
|
||||
# - v1.0.0 (2026-02-14): Created - runs summary generation and mbank archival in background
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Seedgo v3.0 compliant (imports, architecture, error handling)
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: post_close_runner.py
|
||||
# Description: Background post-close processing
|
||||
# Version: 1.2.0
|
||||
# Created: 2026-02-14
|
||||
# Modified: 2026-02-14
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Post-Close Background Runner
|
||||
@@ -25,10 +17,6 @@ already running, this one exits silently (the running instance will pick up
|
||||
all unprocessed plans since it scans ALL of them).
|
||||
|
||||
This script lives inside the flow branch so handler import guards allow it.
|
||||
|
||||
Note: This is a background utility script, not a command-routable module.
|
||||
It has no handle_command() or --help because it is never invoked by users
|
||||
or drone directly - only by close_plan.py via subprocess.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -39,7 +27,8 @@ from pathlib import Path
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
# External: Prax logger
|
||||
# External: CLI console (Rich display) and Prax logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
MODULE_NAME = "post_close_runner"
|
||||
@@ -51,6 +40,44 @@ LOCK_FILE = FLOW_ROOT / ".post_close_runner.lock"
|
||||
from aipass.flow.apps.handlers.mbank.process import process_closed_plans
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point.
|
||||
|
||||
This module is a background utility runner, not a user-facing command.
|
||||
It responds to 'post_close' for drone routing compatibility
|
||||
and supports --help / -h for introspection.
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
if command != "post_close":
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
# Run the post-close processing directly (foreground)
|
||||
if not _acquire_lock():
|
||||
console.print("[yellow]Another instance is already running[/yellow]")
|
||||
return True
|
||||
|
||||
try:
|
||||
process_closed_plans()
|
||||
console.print("[green]Processing complete[/green]")
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
||||
console.print(f"[red]Processing failed: {e}[/red]")
|
||||
finally:
|
||||
_release_lock()
|
||||
|
||||
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():
|
||||
@@ -74,7 +101,35 @@ def _release_lock():
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to release lock file: {e}")
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("post_close_runner Module")
|
||||
console.print("Background post-close processing — runs memory bank archival")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/mbank/")
|
||||
console.print(" - process.py (process_closed_plans — scan and archive closed plans)")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display help for this background runner."""
|
||||
console.print(f"Usage: python {Path(__file__).name}")
|
||||
console.print()
|
||||
console.print("Post-Close Background Runner")
|
||||
console.print("Runs memory bank archival as a background process.")
|
||||
console.print("Called by close_plan.py via subprocess — not intended for direct use.")
|
||||
console.print()
|
||||
console.print("Options:")
|
||||
console.print(" -h, --help Show this help message")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if '--help' in sys.argv or '-h' in sys.argv:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
if not _acquire_lock():
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: registry_monitor.py - Registry auto-healing and file watching module
|
||||
# Date: 2025-11-21
|
||||
# Version: 2.0.0
|
||||
# Category: flow/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.0.0 (2026-01-20): Migrated to Trigger event system - fires events, doesn't handle
|
||||
# - v1.0.0 (2025-11-21): Initial port from archive_temp with Python watchdog integration
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Seedgo v3.0 compliant (imports, architecture, error handling)
|
||||
# - Module-level logging (3-tier pattern)
|
||||
# - Event-driven: fires trigger events, handlers in Trigger branch
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: registry_monitor.py
|
||||
# Description: Registry auto-healing and file watching module
|
||||
# Version: 2.1.0
|
||||
# Created: 2025-11-21
|
||||
# Modified: 2025-11-21
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Registry Monitor Module - Auto-Healing PLAN Registry
|
||||
|
||||
Monitors filesystem for PLAN file changes and fires trigger events.
|
||||
Thin orchestrator for filesystem monitoring and registry healing.
|
||||
All business logic delegated to handlers/registry/monitor_ops.py.
|
||||
|
||||
Architecture (v2.0):
|
||||
- PlanFileWatcher detects filesystem events via Python watchdog
|
||||
- Events are fired via trigger.fire() (plan_file_created, plan_file_deleted, plan_file_moved)
|
||||
- Events are fired via the trigger event bus (plan_file_created, plan_file_deleted, plan_file_moved)
|
||||
- Trigger handlers in trigger/apps/handlers/events/plan_file.py update the registry
|
||||
- Decoupled: Flow fires events, Trigger handles reactions
|
||||
|
||||
@@ -47,18 +38,12 @@ Commands:
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple, Optional
|
||||
from datetime import datetime, timezone
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
# External: Prax logger
|
||||
@@ -72,7 +57,14 @@ from aipass.cli.apps.modules import console
|
||||
|
||||
# Registry handlers
|
||||
from aipass.flow.apps.handlers.registry.load_registry import load_registry
|
||||
from aipass.flow.apps.handlers.registry.save_registry import save_registry
|
||||
|
||||
# Implementation handler
|
||||
from aipass.flow.apps.handlers.registry.monitor_ops import (
|
||||
scan_plan_files_impl,
|
||||
start_monitoring_impl,
|
||||
stop_monitoring_impl,
|
||||
get_status_impl,
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
@@ -92,375 +84,67 @@ def _find_repo_root() -> Path:
|
||||
|
||||
REPO_ROOT = _find_repo_root()
|
||||
ECOSYSTEM_ROOT = REPO_ROOT # Scan from repo root
|
||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||
|
||||
# PLAN file pattern
|
||||
PLAN_PATTERN = re.compile(r'^FPLAN-\d{4}\.md$')
|
||||
|
||||
# Directories to ignore during monitoring
|
||||
IGNORE_FOLDERS = {
|
||||
# Development and version control
|
||||
".git", ".venv", "venv", "__pycache__", "node_modules",
|
||||
".pytest_cache", "dist", "build", ".idea", ".vscode",
|
||||
|
||||
# Backup and archive
|
||||
"backup", "backups", ".backup", "archive", ".archive",
|
||||
"backup_system", "archive_temp", "processed_plans",
|
||||
|
||||
# Memory and admin
|
||||
"MEMORY_BANK", "admin", "aipass-help",
|
||||
|
||||
# User directories
|
||||
".local", "Downloads", "downloads",
|
||||
|
||||
# System directories (permission issues)
|
||||
"proc", "sys", "dev", "run", "boot", "lost+found",
|
||||
"timeshift", "snapshots", ".snapshots"
|
||||
}
|
||||
|
||||
# Global observer instance
|
||||
_observer: Any = None
|
||||
_observer_lock = threading.Lock()
|
||||
|
||||
# Event deduplication
|
||||
_recent_events: List[Tuple[str, str, float]] = [] # [(event_type, plan_num, timestamp)]
|
||||
DEDUPE_WINDOW = 2.0 # seconds
|
||||
|
||||
# =============================================
|
||||
# FILE WATCHER CLASS
|
||||
# =============================================
|
||||
|
||||
class PlanFileWatcher(FileSystemEventHandler):
|
||||
"""Monitors PLAN file changes and fires trigger events"""
|
||||
|
||||
def on_created(self, event):
|
||||
"""Handle file creation events - fires trigger event"""
|
||||
if not event.is_directory and self._is_plan_file(str(event.src_path)):
|
||||
file_path = Path(str(event.src_path))
|
||||
plan_num = self._get_plan_number(file_path)
|
||||
|
||||
if plan_num and not self._is_duplicate_event("created", plan_num):
|
||||
logger.info(f"[{MODULE_NAME}] New PLAN file detected: {file_path.name}")
|
||||
self._schedule_fire_created(file_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
"""Handle file deletion events - fires trigger event"""
|
||||
if not event.is_directory and self._is_plan_file(str(event.src_path)):
|
||||
file_path = Path(str(event.src_path))
|
||||
plan_num = self._get_plan_number(file_path)
|
||||
|
||||
if plan_num and not self._is_duplicate_event("deleted", plan_num):
|
||||
logger.info(f"[{MODULE_NAME}] PLAN file deleted: {file_path.name}")
|
||||
self._schedule_fire_deleted(file_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
"""Handle file move/rename events - fires trigger event"""
|
||||
if not event.is_directory and self._is_plan_file(str(event.dest_path)):
|
||||
src_path = Path(str(event.src_path))
|
||||
dest_path = Path(str(event.dest_path))
|
||||
plan_num = self._get_plan_number(dest_path)
|
||||
|
||||
if plan_num and not self._is_duplicate_event("moved", plan_num):
|
||||
logger.info(f"[{MODULE_NAME}] PLAN file moved: {src_path.name} -> {dest_path}")
|
||||
self._schedule_fire_moved(src_path, dest_path)
|
||||
|
||||
def _is_plan_file(self, file_path: str) -> bool:
|
||||
"""Check if file is a PLAN file"""
|
||||
return PLAN_PATTERN.match(Path(file_path).name) is not None
|
||||
|
||||
def _get_plan_number(self, file_path: Path) -> Optional[str]:
|
||||
"""Extract plan number from filename (e.g., FPLAN-0001.md -> 0001)"""
|
||||
match = re.search(r'FPLAN-(\d{4})\.md$', file_path.name)
|
||||
return match.group(1) if match else None
|
||||
|
||||
def _is_duplicate_event(self, event_type: str, plan_num: str) -> bool:
|
||||
"""Check if this is a duplicate recent event"""
|
||||
global _recent_events
|
||||
now = time.time()
|
||||
|
||||
# Clean old events
|
||||
_recent_events = [(et, pn, ts) for et, pn, ts in _recent_events
|
||||
if now - ts < DEDUPE_WINDOW]
|
||||
|
||||
# Check for duplicates
|
||||
for et, pn, ts in _recent_events:
|
||||
if et == event_type and pn == plan_num:
|
||||
return True
|
||||
|
||||
# Add to recent events
|
||||
_recent_events.append((event_type, plan_num, now))
|
||||
return False
|
||||
|
||||
def _schedule_fire_created(self, file_path: Path):
|
||||
"""Schedule trigger event with delay to avoid duplicate events"""
|
||||
timer = threading.Timer(0.5, self._fire_plan_file_created, args=(file_path,))
|
||||
timer.start()
|
||||
|
||||
def _schedule_fire_deleted(self, file_path: Path):
|
||||
"""Schedule trigger event with delay to avoid duplicate events"""
|
||||
timer = threading.Timer(0.5, self._fire_plan_file_deleted, args=(file_path,))
|
||||
timer.start()
|
||||
|
||||
def _schedule_fire_moved(self, src_path: Path, dest_path: Path):
|
||||
"""Schedule trigger event with delay to avoid duplicate events"""
|
||||
timer = threading.Timer(0.5, self._fire_plan_file_moved, args=(src_path, dest_path))
|
||||
timer.start()
|
||||
|
||||
def _fire_plan_file_created(self, file_path: Path):
|
||||
"""Fire plan_file_created event - Trigger handles registry update"""
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('plan_file_created', path=str(file_path))
|
||||
except ImportError:
|
||||
logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_created event not fired for {file_path.name}")
|
||||
|
||||
def _fire_plan_file_deleted(self, file_path: Path):
|
||||
"""Fire plan_file_deleted event - Trigger handles registry update"""
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('plan_file_deleted', path=str(file_path))
|
||||
except ImportError:
|
||||
logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_deleted event not fired for {file_path.name}")
|
||||
|
||||
def _fire_plan_file_moved(self, src_path: Path, dest_path: Path):
|
||||
"""Fire plan_file_moved event - Trigger handles registry update"""
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('plan_file_moved', src_path=str(src_path), dest_path=str(dest_path))
|
||||
except ImportError:
|
||||
logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_moved event not fired for {dest_path.name}")
|
||||
|
||||
|
||||
# =============================================
|
||||
# SCAN AND HEAL FUNCTION
|
||||
# THIN ORCHESTRATION WRAPPERS
|
||||
# =============================================
|
||||
|
||||
def _fire_event(event_name: str, **kwargs) -> bool:
|
||||
"""
|
||||
Fire a trigger event (internal helper)
|
||||
|
||||
Args:
|
||||
event_name: Name of the event to fire
|
||||
**kwargs: Event data
|
||||
|
||||
Returns:
|
||||
True if event fired successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire(event_name, **kwargs)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning(f"[{MODULE_NAME}] Trigger not available - {event_name} event not fired")
|
||||
return False
|
||||
|
||||
|
||||
def scan_plan_files() -> Dict[str, Any]:
|
||||
"""
|
||||
Scan ecosystem for PLAN files and fire events to heal registry
|
||||
Scan ecosystem for PLAN files and fire events to heal registry (thin orchestrator)
|
||||
|
||||
Fires events for:
|
||||
- Missing registry entries (plan_file_created)
|
||||
- Orphaned entries (plan_file_deleted)
|
||||
- Location mismatches (plan_file_moved)
|
||||
- Duplicate plan numbers are auto-renumbered on filesystem, then fire plan_file_created
|
||||
|
||||
Architecture (v2.0):
|
||||
- This function DETECTS changes and FIRES events
|
||||
- Trigger handlers in plan_file.py HANDLE the registry updates
|
||||
- Flow never touches registry directly during scan
|
||||
Delegates to monitor_ops handler for implementation.
|
||||
|
||||
Returns:
|
||||
Dict with scan results and event stats
|
||||
"""
|
||||
logger.info(f"[{MODULE_NAME}] Starting PLAN file scan from: {ECOSYSTEM_ROOT}")
|
||||
return scan_plan_files_impl(
|
||||
ecosystem_root=ECOSYSTEM_ROOT,
|
||||
load_registry=load_registry,
|
||||
)
|
||||
|
||||
# Find all PLAN files (detect duplicates)
|
||||
plan_files: Dict[str, Path] = {}
|
||||
duplicates: Dict[str, List[Path]] = {}
|
||||
|
||||
def handle_walk_error(error):
|
||||
"""Handle permission errors during os.walk"""
|
||||
if not isinstance(error, PermissionError):
|
||||
logger.warning(f"[{MODULE_NAME}] Error during scan: {error}")
|
||||
|
||||
# Use os.walk() with error handling
|
||||
for root, dirs, files in os.walk(str(ECOSYSTEM_ROOT), topdown=True, onerror=handle_walk_error):
|
||||
# Skip ignored directories (modify dirs in-place to prevent descent)
|
||||
dirs[:] = [d for d in dirs if not any(ignored in d for ignored in IGNORE_FOLDERS)]
|
||||
|
||||
# Check for PLAN files in this directory
|
||||
for filename in files:
|
||||
if PLAN_PATTERN.match(filename):
|
||||
file_path = Path(root) / filename
|
||||
match = re.search(r'FPLAN-(\d{4})\.md$', filename)
|
||||
if match:
|
||||
plan_number = match.group(1)
|
||||
|
||||
# Duplicate detection
|
||||
if plan_number in plan_files:
|
||||
if plan_number not in duplicates:
|
||||
duplicates[plan_number] = [plan_files[plan_number]]
|
||||
duplicates[plan_number].append(file_path)
|
||||
logger.warning(f"[{MODULE_NAME}] Duplicate FPLAN-{plan_number} found: {file_path}")
|
||||
else:
|
||||
plan_files[plan_number] = file_path
|
||||
|
||||
# Auto-renumber duplicates (keep first, renumber rest)
|
||||
renumbered: List[Dict[str, str]] = []
|
||||
if duplicates:
|
||||
logger.warning(f"[{MODULE_NAME}] Found {len(duplicates)} duplicate PLAN files")
|
||||
|
||||
# Get next available plan number
|
||||
current_max = max(int(num) for num in plan_files.keys()) if plan_files else 0
|
||||
next_available = current_max + 1
|
||||
|
||||
for plan_num, paths in duplicates.items():
|
||||
# Keep first occurrence, renumber the rest
|
||||
for dup_path in paths[1:]: # Skip first path (already in plan_files)
|
||||
old_name = dup_path.name
|
||||
new_num = f"{next_available:04d}"
|
||||
new_name = f"FPLAN-{new_num}.md"
|
||||
new_path = dup_path.parent / new_name
|
||||
|
||||
try:
|
||||
# Rename file on filesystem
|
||||
dup_path.rename(new_path)
|
||||
logger.info(f"[{MODULE_NAME}] Auto-renumbered: {old_name} -> {new_name} at {dup_path.parent}")
|
||||
|
||||
# Add to plan_files with new number
|
||||
plan_files[new_num] = new_path
|
||||
renumbered.append({
|
||||
"old_number": plan_num,
|
||||
"new_number": new_num,
|
||||
"path": str(new_path)
|
||||
})
|
||||
|
||||
next_available += 1
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Failed to renumber {old_name}: {e}")
|
||||
|
||||
# Load current registry to compare (read-only - we don't modify it here)
|
||||
registry = load_registry()
|
||||
plans = registry.get("plans", {})
|
||||
|
||||
# Track events fired
|
||||
added: List[str] = []
|
||||
updated: List[str] = []
|
||||
removed: List[str] = []
|
||||
|
||||
# Fire events for missing files (not in registry)
|
||||
for plan_number, file_path in plan_files.items():
|
||||
if plan_number not in plans:
|
||||
# File exists but not in registry - fire created event
|
||||
if _fire_event('plan_file_created', path=str(file_path)):
|
||||
added.append(plan_number)
|
||||
logger.info(f"[{MODULE_NAME}] Fired plan_file_created for FPLAN-{plan_number}")
|
||||
else:
|
||||
# Check if location changed (file moved)
|
||||
current_path = plans[plan_number].get("file_path", "")
|
||||
if current_path != str(file_path):
|
||||
# Fire moved event
|
||||
if _fire_event('plan_file_moved', src_path=current_path, dest_path=str(file_path)):
|
||||
updated.append(plan_number)
|
||||
logger.info(f"[{MODULE_NAME}] Fired plan_file_moved for FPLAN-{plan_number}")
|
||||
|
||||
# Fire events for orphaned registry entries (in registry but file doesn't exist)
|
||||
for plan_number in list(plans.keys()):
|
||||
if plan_number not in plan_files:
|
||||
# Registry entry but no file - fire deleted event
|
||||
file_path = plans[plan_number].get("file_path", f"FPLAN-{plan_number}.md")
|
||||
if _fire_event('plan_file_deleted', path=file_path):
|
||||
removed.append(plan_number)
|
||||
logger.info(f"[{MODULE_NAME}] Fired plan_file_deleted for FPLAN-{plan_number}")
|
||||
|
||||
# Log event results
|
||||
if added or updated or removed or renumbered:
|
||||
logger.info(f"[{MODULE_NAME}] Events fired - Created: {len(added)}, Moved: {len(updated)}, Deleted: {len(removed)}, Renumbered: {len(renumbered)}")
|
||||
|
||||
# Reload registry to get updated count (after handlers processed events)
|
||||
registry = load_registry()
|
||||
total_plans = len(registry.get("plans", {}))
|
||||
|
||||
logger.info(f"[{MODULE_NAME}] Scan complete - {total_plans} PLAN files in registry")
|
||||
|
||||
return {
|
||||
"total_plans": total_plans,
|
||||
"added": added,
|
||||
"updated": updated,
|
||||
"removed": removed,
|
||||
"renumbered": renumbered,
|
||||
"healing_performed": len(added) + len(updated) + len(removed) + len(renumbered) > 0
|
||||
}
|
||||
|
||||
|
||||
# =============================================
|
||||
# MONITOR CONTROL
|
||||
# =============================================
|
||||
|
||||
def start_monitoring():
|
||||
"""Start PLAN file monitoring with watchdog"""
|
||||
global _observer
|
||||
"""Start PLAN file monitoring with watchdog (thin orchestrator)
|
||||
|
||||
with _observer_lock:
|
||||
if _observer and _observer.is_alive():
|
||||
logger.info(f"[{MODULE_NAME}] Monitor already running")
|
||||
console.print("[yellow]Monitor is already running[/yellow]")
|
||||
return False
|
||||
|
||||
try:
|
||||
observer = Observer()
|
||||
observer.schedule(PlanFileWatcher(), str(ECOSYSTEM_ROOT), recursive=True)
|
||||
observer.start()
|
||||
_observer = observer
|
||||
logger.info(f"[{MODULE_NAME}] PLAN file monitor started - watching {ECOSYSTEM_ROOT}")
|
||||
console.print(f"[green]✓[/green] Monitor started - watching {ECOSYSTEM_ROOT}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{MODULE_NAME}] Error starting monitor: {e}")
|
||||
console.print(f"[red]Error starting monitor: {e}[/red]")
|
||||
return False
|
||||
Returns:
|
||||
True if started successfully, False otherwise
|
||||
"""
|
||||
result = start_monitoring_impl(ecosystem_root=ECOSYSTEM_ROOT)
|
||||
# Module handles display
|
||||
status = result.get("status", "")
|
||||
if status == "already_running":
|
||||
console.print("[yellow]Monitor is already running[/yellow]")
|
||||
elif status == "started":
|
||||
console.print(f"[green]OK[/green] {result['message']}")
|
||||
elif status == "error":
|
||||
console.print(f"[red]{result['message']}[/red]")
|
||||
return result.get("success", False)
|
||||
|
||||
|
||||
def stop_monitoring():
|
||||
"""Stop PLAN file monitoring"""
|
||||
global _observer
|
||||
"""Stop PLAN file monitoring (thin orchestrator)
|
||||
|
||||
with _observer_lock:
|
||||
if _observer and _observer.is_alive():
|
||||
_observer.stop()
|
||||
_observer.join()
|
||||
_observer = None
|
||||
logger.info(f"[{MODULE_NAME}] PLAN file monitor stopped")
|
||||
console.print("[green]✓[/green] Monitor stopped")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"[{MODULE_NAME}] Monitor is not running")
|
||||
console.print("[yellow]Monitor is not running[/yellow]")
|
||||
return False
|
||||
Returns:
|
||||
True if stopped successfully, False otherwise
|
||||
"""
|
||||
result = stop_monitoring_impl()
|
||||
# Module handles display
|
||||
status = result.get("status", "")
|
||||
if status == "stopped":
|
||||
console.print("[green]OK[/green] Monitor stopped")
|
||||
elif status == "not_running":
|
||||
console.print("[yellow]Monitor is not running[/yellow]")
|
||||
return result.get("success", False)
|
||||
|
||||
|
||||
def get_status() -> Dict[str, Any]:
|
||||
"""Get monitoring status"""
|
||||
global _observer
|
||||
|
||||
registry = load_registry()
|
||||
total_plans = len(registry.get("plans", {}))
|
||||
open_plans = sum(1 for p in registry.get("plans", {}).values() if p.get("status") == "open")
|
||||
|
||||
with _observer_lock:
|
||||
is_running = _observer and _observer.is_alive()
|
||||
|
||||
return {
|
||||
"module": MODULE_NAME,
|
||||
"version": "2.0.0",
|
||||
"monitoring_active": is_running,
|
||||
"watch_location": str(ECOSYSTEM_ROOT),
|
||||
"total_plans": total_plans,
|
||||
"open_plans": open_plans,
|
||||
"ignore_folders": len(IGNORE_FOLDERS)
|
||||
}
|
||||
"""Get monitoring status (thin orchestrator)"""
|
||||
return get_status_impl(
|
||||
ecosystem_root=ECOSYSTEM_ROOT,
|
||||
load_registry=load_registry,
|
||||
)
|
||||
|
||||
|
||||
# =============================================
|
||||
@@ -611,6 +295,7 @@ def print_introspection():
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" • [cyan]handlers/registry/monitor_ops.py (implementation)[/cyan]")
|
||||
console.print(" • [cyan]handlers/registry/load_registry.py[/cyan]")
|
||||
console.print(" • [cyan]handlers/registry/save_registry.py[/cyan]")
|
||||
console.print()
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: restore_plan.py - PLAN restore module (reopen closed plans)
|
||||
# Date: 2025-11-22
|
||||
# Version: 1.3.0
|
||||
# Category: flow/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.3.0 (2025-11-22): Use NEWEST backup when multiple exist, show restore location in output
|
||||
# - v1.2.1 (2025-11-22): Fixed relative path handling (convert "flow" → absolute path)
|
||||
# - v1.2.0 (2025-11-22): Fixed recovery to restore plans to ORIGINAL location (not hardcoded flow)
|
||||
# - v1.1.0 (2025-11-22): Added auto-recovery from processed_plans
|
||||
# - v1.0.0 (2025-11-21): Initial creation - restore closed plans to open status
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Seedgo v3.0 compliant (imports, architecture, error handling)
|
||||
# ==============================================
|
||||
# =================== AIPass ====================
|
||||
# Name: restore_plan.py
|
||||
# Description: PLAN restore module (reopen closed plans)
|
||||
# Version: 1.5.0
|
||||
# Created: 2025-11-22
|
||||
# Modified: 2025-11-22
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Restore PLAN Module
|
||||
|
||||
Thin orchestrator for plan restore workflow (reopening closed plans).
|
||||
All business logic delegated to handlers.
|
||||
Module handles all display output.
|
||||
|
||||
Usage:
|
||||
From flow.py: flow restore <number>
|
||||
@@ -30,12 +20,10 @@ Usage:
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from shutil import copy2
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/
|
||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||
|
||||
# External: Prax logger
|
||||
@@ -64,12 +52,52 @@ from aipass.flow.apps.handlers.plan.display import (
|
||||
from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_local
|
||||
from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central
|
||||
|
||||
# Internal: Restore operations handler (implementation)
|
||||
from aipass.flow.apps.handlers.plan.restore_ops import (
|
||||
recover_plan_from_backup as _recover_plan_from_backup_impl,
|
||||
restore_plan_impl,
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "restore_plan"
|
||||
|
||||
# =============================================
|
||||
# DISPLAY HELPERS
|
||||
# =============================================
|
||||
|
||||
def _display_messages(messages: List[Dict[str, Any]]):
|
||||
"""Render handler result messages to console
|
||||
|
||||
Args:
|
||||
messages: List of message dicts from handler with type/text keys
|
||||
"""
|
||||
for msg in messages:
|
||||
msg_type = msg.get("type", "")
|
||||
|
||||
if msg_type == "error":
|
||||
error_type = msg.get("error_type", "general")
|
||||
plan_key = msg.get("plan_key", "")
|
||||
details = msg.get("details", None)
|
||||
console.print(format_restore_error(error_type, plan_key, details=details))
|
||||
|
||||
elif msg_type == "warning":
|
||||
console.print(f"[yellow]{msg['text']}[/yellow]")
|
||||
|
||||
elif msg_type == "dim":
|
||||
console.print(f"[dim]{msg['text']}[/dim]")
|
||||
|
||||
elif msg_type == "success":
|
||||
console.print(f"[green]{msg['text']}[/green]")
|
||||
|
||||
elif msg_type == "restore_header":
|
||||
console.print(format_restore_header(msg["plan_key"], msg["plan_info"]))
|
||||
|
||||
elif msg_type == "restore_success":
|
||||
console.print(format_restore_success(msg["plan_key"], msg.get("location")))
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
@@ -84,6 +112,7 @@ def print_introspection():
|
||||
console.print()
|
||||
|
||||
console.print(" [cyan]handlers/plan/[/cyan]")
|
||||
console.print(" [dim]- restore_ops.py (implementation)[/dim]")
|
||||
console.print(" [dim]- command_parser.py[/dim]")
|
||||
console.print(" [dim]- validator.py[/dim]")
|
||||
console.print(" [dim]- display.py[/dim]")
|
||||
@@ -108,7 +137,9 @@ def print_introspection():
|
||||
|
||||
def recover_plan_from_backup(plan_key: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Attempt to recover a plan from processed_plans backup
|
||||
Attempt to recover a plan from processed_plans backup (thin orchestrator)
|
||||
|
||||
Delegates to restore_ops handler for implementation.
|
||||
|
||||
Args:
|
||||
plan_key: Normalized plan number (e.g., "0165")
|
||||
@@ -116,88 +147,11 @@ def recover_plan_from_backup(plan_key: str) -> tuple[bool, str]:
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
# Check processed_plans directory
|
||||
processed_plans = _PKG_ROOT / "backup_system" / "processed_plans"
|
||||
plan_file = processed_plans / f"FPLAN-{plan_key}.md"
|
||||
|
||||
# CRITICAL: If base file doesn't exist, or if timestamp variants exist, use the NEWEST backup
|
||||
# This handles cases where plan was closed multiple times from different locations
|
||||
variants = list(processed_plans.glob(f"FPLAN-{plan_key}*.md"))
|
||||
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():
|
||||
return False, f"FPLAN-{plan_key} not found in backups"
|
||||
|
||||
# Read plan file to extract original location from header
|
||||
try:
|
||||
with open(plan_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse location from header (e.g., "**Location**: /path/to/dir")
|
||||
original_location = None
|
||||
for line in content.split('\n')[:20]: # Check first 20 lines
|
||||
if line.startswith("**Location**:"):
|
||||
original_location = line.split("**Location**:")[1].strip()
|
||||
break
|
||||
|
||||
# If location not found in header, default to FLOW_ROOT
|
||||
if not original_location:
|
||||
original_location = str(FLOW_ROOT)
|
||||
|
||||
# CRITICAL: Convert relative paths to absolute paths
|
||||
# If location is relative (like "flow"), resolve it
|
||||
if not original_location.startswith('/'):
|
||||
# Relative path - resolve against _PKG_ROOT
|
||||
if original_location == "flow":
|
||||
original_location = str(FLOW_ROOT)
|
||||
else:
|
||||
# Try resolving relative to _PKG_ROOT
|
||||
potential_path = _PKG_ROOT / original_location
|
||||
if potential_path.exists():
|
||||
original_location = str(potential_path)
|
||||
else:
|
||||
# Fallback to FLOW_ROOT
|
||||
original_location = str(FLOW_ROOT)
|
||||
|
||||
# Determine relative path
|
||||
original_path = Path(original_location)
|
||||
if original_path == FLOW_ROOT:
|
||||
relative_path = "flow"
|
||||
elif original_path == _PKG_ROOT:
|
||||
relative_path = "root"
|
||||
else:
|
||||
try:
|
||||
relative_path = str(original_path.relative_to(_PKG_ROOT))
|
||||
except ValueError:
|
||||
relative_path = str(original_path)
|
||||
|
||||
except Exception as e:
|
||||
# If parsing fails, default to FLOW_ROOT
|
||||
original_location = str(FLOW_ROOT)
|
||||
relative_path = "flow"
|
||||
|
||||
# Copy file to ORIGINAL location (preserve backup)
|
||||
target = Path(original_location) / f"FPLAN-{plan_key}.md"
|
||||
copy2(plan_file, target)
|
||||
|
||||
# Create minimal registry entry
|
||||
registry = load_registry()
|
||||
registry["plans"][plan_key] = {
|
||||
"location": original_location,
|
||||
"relative_path": relative_path,
|
||||
"file_path": str(target),
|
||||
"status": "closed",
|
||||
"created": datetime.now(timezone.utc).isoformat(),
|
||||
"subject": "Recovered from backup",
|
||||
"closed": datetime.now(timezone.utc).isoformat(),
|
||||
"closed_reason": "recovered_from_backup",
|
||||
"template_type": "default"
|
||||
}
|
||||
save_registry(registry)
|
||||
|
||||
return True, f"Recovered FPLAN-{plan_key} from {plan_file.name} to {original_location}"
|
||||
return _recover_plan_from_backup_impl(
|
||||
plan_key,
|
||||
load_registry=load_registry,
|
||||
save_registry=save_registry,
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# RESTORE PLAN WORKFLOW
|
||||
@@ -207,13 +161,8 @@ def restore_plan(plan_num: str | None) -> bool:
|
||||
"""
|
||||
Orchestrate plan restore workflow (thin orchestrator)
|
||||
|
||||
Restores a closed plan back to open status by updating registry metadata.
|
||||
Does NOT move files - file must already be at registered location.
|
||||
|
||||
Delegates all business logic to handlers:
|
||||
- Validation: validator handler
|
||||
- Registry ops: registry handlers
|
||||
- Display: display handler
|
||||
Delegates all business logic to restore_ops handler.
|
||||
Module handles all display output.
|
||||
|
||||
Args:
|
||||
plan_num: Plan number (e.g., "0001" or "1" or "42")
|
||||
@@ -221,107 +170,25 @@ def restore_plan(plan_num: str | None) -> bool:
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not plan_num:
|
||||
logger.warning(f"[{MODULE_NAME}] Plan number required for restore")
|
||||
console.print(format_restore_error("invalid_number", ""))
|
||||
return False
|
||||
# Import scan_plan_files here (lazy import to avoid circular dependency)
|
||||
from aipass.flow.apps.modules.registry_monitor import scan_plan_files
|
||||
|
||||
try:
|
||||
# 0. AUTO-HEAL: Run registry scan to detect moved files (self-healing)
|
||||
from aipass.flow.apps.modules.registry_monitor import scan_plan_files
|
||||
scan_plan_files() # Auto-detects files not in registry
|
||||
logger.info(f"[{MODULE_NAME}] Auto-heal scan completed")
|
||||
result = restore_plan_impl(
|
||||
plan_num=plan_num,
|
||||
# Inject dependencies
|
||||
normalize_plan_number=normalize_plan_number,
|
||||
load_registry=load_registry,
|
||||
save_registry=save_registry,
|
||||
validate_plan_exists=validate_plan_exists,
|
||||
recover_plan_from_backup_fn=recover_plan_from_backup,
|
||||
scan_plan_files=scan_plan_files,
|
||||
update_dashboard_local=update_dashboard_local,
|
||||
push_to_plans_central=push_to_plans_central,
|
||||
)
|
||||
|
||||
# 1. VALIDATE: Normalize plan number (handler)
|
||||
plan_key = normalize_plan_number(plan_num)
|
||||
|
||||
# 2. LOAD DATA: Get registry (service)
|
||||
registry = load_registry()
|
||||
|
||||
# 3. VALIDATE: Check plan exists (handler)
|
||||
exists, error_msg = validate_plan_exists(plan_key, registry)
|
||||
if not exists:
|
||||
# AUTO-RECOVERY: Try to recover from processed_plans
|
||||
console.print(f"[yellow]FPLAN-{plan_key} not in registry - attempting recovery...[/yellow]")
|
||||
recovered, recovery_msg = recover_plan_from_backup(plan_key)
|
||||
|
||||
if recovered:
|
||||
console.print(f"[green]✓ {recovery_msg}[/green]")
|
||||
# Reload registry with recovered plan
|
||||
registry = load_registry()
|
||||
plan_info = registry["plans"][plan_key]
|
||||
plan_file = Path(plan_info.get("file_path", ""))
|
||||
else:
|
||||
logger.warning(f"[{MODULE_NAME}] {error_msg} - Recovery failed: {recovery_msg}")
|
||||
console.print(format_restore_error("not_found", plan_key))
|
||||
console.print(f"[dim]Recovery attempt: {recovery_msg}[/dim]")
|
||||
return False
|
||||
else:
|
||||
plan_info = registry["plans"][plan_key]
|
||||
plan_file = Path(plan_info.get("file_path", ""))
|
||||
|
||||
# 4. VALIDATE: Check plan is closed
|
||||
if plan_info.get("status") != "closed":
|
||||
logger.warning(f"[{MODULE_NAME}] FPLAN-{plan_key} is already open")
|
||||
console.print(format_restore_error("already_open", plan_key))
|
||||
return False
|
||||
|
||||
# 5. VALIDATE: Check file exists at registered location
|
||||
if not plan_file.exists():
|
||||
logger.warning(f"[{MODULE_NAME}] File not found at {plan_file}")
|
||||
console.print(format_restore_error("file_missing", plan_key))
|
||||
return False
|
||||
|
||||
# 6. DISPLAY: Show plan info before restore (handler)
|
||||
console.print(format_restore_header(plan_key, plan_info))
|
||||
|
||||
# 7. UPDATE REGISTRY: Restore to open status
|
||||
plan_info['status'] = 'open'
|
||||
|
||||
# Remove all close-related metadata
|
||||
plan_info.pop('closed', None)
|
||||
plan_info.pop('closed_reason', None)
|
||||
plan_info.pop('memory_created', None)
|
||||
plan_info.pop('memory_created_date', None)
|
||||
plan_info.pop('memory_file', None)
|
||||
|
||||
save_registry(registry)
|
||||
logger.info(f"[{MODULE_NAME}] Restored FPLAN-{plan_key} to open status")
|
||||
|
||||
# 8. UPDATE DASHBOARDS: Sync dashboard files (handlers)
|
||||
dashboard_success = update_dashboard_local()
|
||||
central_success = push_to_plans_central()
|
||||
|
||||
# Log dashboard update results (3-tier: modules log, handlers don't)
|
||||
if not dashboard_success:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json")
|
||||
if not central_success:
|
||||
logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json")
|
||||
|
||||
# 9. DISPLAY: Success message with location (handler)
|
||||
restored_location = plan_info.get("location", "unknown")
|
||||
console.print(format_restore_success(plan_key, restored_location))
|
||||
|
||||
# Fire trigger event for plan restore (optional - trigger module may not be available)
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
trigger.fire('plan_restored', plan_number=plan_key, location=restored_location)
|
||||
except ImportError:
|
||||
logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire")
|
||||
|
||||
return True
|
||||
|
||||
except ValueError:
|
||||
error_msg = f"Invalid plan number: {plan_num}"
|
||||
logger.warning(f"[{MODULE_NAME}] {error_msg}")
|
||||
console.print(format_restore_error("invalid_number", plan_num))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error restoring plan: {e}"
|
||||
logger.error(f"[{MODULE_NAME}] {error_msg}")
|
||||
console.print(format_restore_error("general", details=str(e)))
|
||||
return False
|
||||
# Module handles display
|
||||
_display_messages(result.get("messages", []))
|
||||
return result.get("success", False)
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user